From 0affa544c334aa6804cc787324decac45ba82cce Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Mon, 20 Jan 2025 10:07:00 -0800 Subject: [PATCH] Show component code frame, if available (#48785) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/48785 ## Overview This change adds code frames for component stacks if it differs from the call stack frame. ## Background After adding native stack based stack frames, which we already symbolicate, we had the capability to show a code frame for component stacks as well as the stack frame (if they differ). However, this usually wasn't that useful, because the native component stack was unlikely to be more useful than the component stack (see the comparisons below for key errors). ## Owner stacks With owner stacks the component frame is a lot more useful and in many cases are better at showing the location than the call stack frame. ## Example Screens Before: {F1974436645} After (with owner stacks): {F1974436723} Changelog: [General][Added] - Add owner stack code frames to LogBox Reviewed By: hoxyq Differential Revision: D68285627 fbshipit-source-id: 541cecbd4786fffd1970d2e85e659757e3f81604 --- .../Libraries/LogBox/Data/LogBoxLog.js | 62 +- .../LogBox/Data/__tests__/LogBoxLog-test.js | 946 ++++++++++++++---- .../LogBox/UI/LogBoxInspectorBody.js | 10 +- .../LogBox/UI/LogBoxInspectorCodeFrame.js | 77 +- .../LogBoxInspectorCodeFrame-test.js | 66 +- .../LogBoxInspectorCodeFrame-test.js.snap | 259 +++++ .../__snapshots__/public-api-test.js.snap | 2 + 7 files changed, 1181 insertions(+), 241 deletions(-) diff --git a/packages/react-native/Libraries/LogBox/Data/LogBoxLog.js b/packages/react-native/Libraries/LogBox/Data/LogBoxLog.js index 32cc3323776..be475215db8 100644 --- a/packages/react-native/Libraries/LogBox/Data/LogBoxLog.js +++ b/packages/react-native/Libraries/LogBox/Data/LogBoxLog.js @@ -79,6 +79,7 @@ class LogBoxLog { count: number; level: LogLevel; codeFrame: ?CodeFrame; + componentCodeFrame: ?CodeFrame; isComponentError: boolean; extraData: mixed | void; symbolicated: @@ -140,8 +141,18 @@ class LogBoxLog { } retrySymbolicate(callback?: (status: SymbolicationStatus) => void): void { + let retry = false; if (this.symbolicated.status !== 'COMPLETE') { LogBoxSymbolication.deleteStack(this.stack); + retry = true; + } + if (this.symbolicatedComponentStack.status !== 'COMPLETE') { + LogBoxSymbolication.deleteStack( + convertComponentStateToStack(this.componentStack), + ); + retry = true; + } + if (retry) { this.handleSymbolicate(callback); } } @@ -153,7 +164,10 @@ class LogBoxLog { } handleSymbolicate(callback?: (status: SymbolicationStatus) => void): void { - if (this.symbolicated.status !== 'PENDING') { + if ( + this.symbolicated.status !== 'PENDING' && + this.symbolicated.status !== 'COMPLETE' + ) { this.updateStatus(null, null, null, callback); LogBoxSymbolication.symbolicate(this.stack, this.extraData).then( data => { @@ -163,25 +177,30 @@ class LogBoxLog { this.updateStatus(error, null, null, callback); }, ); - if (this.componentStack != null && this.componentStackType === 'stack') { - this.updateComponentStackStatus(null, null, null, callback); - const componentStackFrames = convertComponentStateToStack( - this.componentStack, - ); - LogBoxSymbolication.symbolicate(componentStackFrames, []).then( - data => { - this.updateComponentStackStatus( - null, - convertStackToComponentStack(data.stack), - null, - callback, - ); - }, - error => { - this.updateComponentStackStatus(error, null, null, callback); - }, - ); - } + } + if ( + this.componentStack != null && + this.componentStackType === 'stack' && + this.symbolicatedComponentStack.status !== 'PENDING' && + this.symbolicatedComponentStack.status !== 'COMPLETE' + ) { + this.updateComponentStackStatus(null, null, null, callback); + const componentStackFrames = convertComponentStateToStack( + this.componentStack, + ); + LogBoxSymbolication.symbolicate(componentStackFrames, []).then( + data => { + this.updateComponentStackStatus( + null, + convertStackToComponentStack(data.stack), + data?.codeFrame, + callback, + ); + }, + error => { + this.updateComponentStackStatus(error, null, null, callback); + }, + ); } } @@ -235,6 +254,9 @@ class LogBoxLog { status: 'FAILED', }; } else if (componentStack != null) { + if (codeFrame) { + this.componentCodeFrame = codeFrame; + } this.symbolicatedComponentStack = { error: null, componentStack, diff --git a/packages/react-native/Libraries/LogBox/Data/__tests__/LogBoxLog-test.js b/packages/react-native/Libraries/LogBox/Data/__tests__/LogBoxLog-test.js index a6d34be82c3..364af49122a 100644 --- a/packages/react-native/Libraries/LogBox/Data/__tests__/LogBoxLog-test.js +++ b/packages/react-native/Libraries/LogBox/Data/__tests__/LogBoxLog-test.js @@ -13,12 +13,36 @@ import type {SymbolicatedStackTrace} from '../../../Core/Devtools/symbolicateStackTrace'; import type {StackFrame} from '../../../Core/NativeExceptionsManager'; +import type {CodeFrame} from '../parseLogBoxLog'; jest.mock('../LogBoxSymbolication', () => { return {__esModule: true, symbolicate: jest.fn(), deleteStack: jest.fn()}; }); -function getLogBoxLog() { +type CodeCodeFrame = $ReadOnly<{ + content: string, + location: ?{ + row: number, + column: number, + ... + }, + fileName: string, +}>; + +const STACK_CODE_FRAME: CodeCodeFrame = { + fileName: '/path/to/Stack.js', + location: {row: 199, column: 0}, + content: '', +}; + +const COMPONENT_CODE_FRAME: CodeCodeFrame = { + fileName: '/path/to/Component.js', + location: {row: 199, column: 0}, + content: 'Component', +}; + +// We can delete this when we delete legacy component stack types. +function getLogBoxLogLegacy() { return new (require('../LogBoxLog').default)({ level: 'warn', isComponentError: false, @@ -40,6 +64,19 @@ function getLogBoxLog() { }); } +function getLogBoxLog() { + return new (require('../LogBoxLog').default)({ + level: 'warn', + isComponentError: false, + message: {content: '...', substitutions: []}, + stack: createStack(['A', 'B', 'C']), + category: 'Message category...', + componentStackType: 'stack', + componentStack: createComponentStack(['A', 'B', 'C']), + codeFrame: null, + }); +} + function getLogBoxSymbolication(): { symbolicate: JestMockFn< $ReadOnlyArray>, @@ -51,12 +88,54 @@ function getLogBoxSymbolication(): { const createStack = (methodNames: Array) => methodNames.map((methodName): StackFrame => ({ - column: null, + column: 0, file: 'file://path/to/file.js', lineNumber: 1, methodName, })); +const createStackForComponentStack = (methodNames: Array) => + methodNames.map((methodName): StackFrame => ({ + column: 0, + file: 'file://path/to/component.js', + lineNumber: 1, + methodName, + })); + +const createComponentStack = (methodNames: Array) => + methodNames.map((methodName): CodeFrame => ({ + collapse: false, + content: methodName, + location: { + row: 1, + column: 0, + }, + fileName: 'file://path/to/component.js', + })); + +function mockSymbolicate( + stack: $ReadOnlyArray, + stackCodeFrame: ?CodeCodeFrame, + componentCodeFrame: ?CodeCodeFrame, +): SymbolicatedStackTrace { + const firstFrame = stack[0]; + if ( + firstFrame != null && + firstFrame.file != null && + firstFrame.file.indexOf('component.js') > 0 + ) { + return { + stack: createStackForComponentStack( + stack.map(frame => `C(${frame.methodName})`), + ), + codeFrame: COMPONENT_CODE_FRAME, + }; + } + return { + stack: createStack(stack.map(frame => `S(${frame.methodName})`)), + codeFrame: STACK_CODE_FRAME, + }; +} // Adds a new task to the end of the microtask queue, so that awaiting this // function will run all queued immediates const runMicrotasks = async () => {}; @@ -65,244 +144,733 @@ describe('LogBoxLog', () => { beforeEach(() => { jest.resetModules(); - getLogBoxSymbolication().symbolicate.mockImplementation(async stack => ({ - stack: createStack(stack.map(frame => `S(${frame.methodName})`)), - codeFrame: null, - })); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => + mockSymbolicate(stack), + ); }); - it('creates a LogBoxLog object', () => { - const log = getLogBoxLog(); + describe('symbolicate legacy component stacks (no symbolication)', () => { + it('creates a LogBoxLog object', () => { + const log = getLogBoxLogLegacy(); - expect(log.level).toEqual('warn'); - expect(log.message).toEqual({content: '...', substitutions: []}); - expect(log.stack).toEqual(createStack(['A', 'B', 'C'])); - expect(log.category).toEqual('Message category...'); - expect(log.componentStack).toEqual([ - { - content: 'LogBoxLog', - fileName: 'LogBoxLog.js', - location: {column: -1, row: 1}, - }, - ]); - expect(log.codeFrame).toEqual({ - fileName: '/path/to/RKJSModules/Apps/CrashReact/CrashReactApp.js', - location: {row: 199, column: 0}, - content: '', + expect(log.level).toEqual('warn'); + expect(log.message).toEqual({content: '...', substitutions: []}); + expect(log.stack).toEqual(createStack(['A', 'B', 'C'])); + expect(log.category).toEqual('Message category...'); + expect(log.componentStack).toEqual([ + { + content: 'LogBoxLog', + fileName: 'LogBoxLog.js', + location: {column: -1, row: 1}, + }, + ]); + expect(log.codeFrame).toEqual({ + fileName: '/path/to/RKJSModules/Apps/CrashReact/CrashReactApp.js', + location: {row: 199, column: 0}, + content: '', + }); + }); + + it('increments LogBoxLog count', () => { + const log = getLogBoxLogLegacy(); + + expect(log.count).toEqual(1); + + log.incrementCount(); + + expect(log.count).toEqual(2); + }); + + it('starts without a symbolicated stack', () => { + const log = getLogBoxLogLegacy(); + + expect(log.symbolicated).toEqual({ + error: null, + stack: null, + status: 'NONE', + }); + }); + + it('updates when symbolication is in progress', () => { + const log = getLogBoxLogLegacy(); + + const callback = jest.fn(); + log.symbolicate(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(1); + expect(log.symbolicated).toEqual({ + error: null, + stack: null, + status: 'PENDING', + }); + + // Symbolicating while pending should not make more requests. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.symbolicate(callback); + expect(callback).not.toBeCalled(); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); + }); + + it('updates when symbolication finishes', async () => { + const log = getLogBoxLogLegacy(); + + const callback = jest.fn(); + log.symbolicate(callback); + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback).toBeCalledWith('COMPLETE'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + + // Do not symbolicate again. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.symbolicate(callback); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(0); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); + }); + + it('updates when symbolication fails', async () => { + const error = new Error('...'); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { + throw error; + }); + + const log = getLogBoxLogLegacy(); + + const callback = jest.fn(); + log.symbolicate(callback); + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback).toBeCalledWith('FAILED'); + expect(log.symbolicated).toEqual({ + error, + stack: null, + status: 'FAILED', + }); + + // Do not symbolicate again, retry if needed. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.symbolicate(callback); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(0); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); + }); + + it('retry updates when symbolication is in progress', () => { + const log = getLogBoxLogLegacy(); + + const callback = jest.fn(); + log.retrySymbolicate(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(1); + expect(log.symbolicated).toEqual({ + error: null, + stack: null, + status: 'PENDING', + }); + + // Symbolicating while pending should not make more requests. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.symbolicate(callback); + expect(callback).not.toBeCalled(); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); + }); + + it('retry updates when symbolication finishes', async () => { + const log = getLogBoxLogLegacy(); + + const callback = jest.fn(); + log.retrySymbolicate(callback); + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback).toBeCalledWith('COMPLETE'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + + // Do not symbolicate again + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.retrySymbolicate(callback); + jest.runAllTicks(); + + expect(callback).toBeCalledTimes(0); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); + }); + + it('retry updates when symbolication fails', async () => { + const error = new Error('...'); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { + throw error; + }); + + const log = getLogBoxLogLegacy(); + + const callback = jest.fn(); + log.retrySymbolicate(callback); + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback).toBeCalledWith('FAILED'); + expect(log.symbolicated).toEqual({ + error, + stack: null, + status: 'FAILED', + }); + + // Retry to symbolicate again. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => ({ + stack: createStack(stack.map(frame => `S(${frame.methodName})`)), + codeFrame: null, + })); + + log.retrySymbolicate(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback).toBeCalledWith('COMPLETE'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); }); }); - it('increments LogBoxLog count', () => { - const log = getLogBoxLog(); + describe('symbolicate component stacks', () => { + it('creates a LogBoxLog object', () => { + const log = getLogBoxLog(); - expect(log.count).toEqual(1); - - log.incrementCount(); - - expect(log.count).toEqual(2); - }); - - it('starts without a symbolicated stack', () => { - const log = getLogBoxLog(); - - expect(log.symbolicated).toEqual({ - error: null, - stack: null, - status: 'NONE', - }); - }); - - it('updates when symbolication is in progress', () => { - const log = getLogBoxLog(); - - const callback = jest.fn(); - log.symbolicate(callback); - - expect(callback).toBeCalledTimes(1); - expect(callback).toBeCalledWith('PENDING'); - expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(1); - expect(log.symbolicated).toEqual({ - error: null, - stack: null, - status: 'PENDING', + expect(log.level).toEqual('warn'); + expect(log.message).toEqual({content: '...', substitutions: []}); + expect(log.stack).toEqual(createStack(['A', 'B', 'C'])); + expect(log.category).toEqual('Message category...'); + expect(log.componentStack).toEqual(createComponentStack(['A', 'B', 'C'])); + expect(log.codeFrame).toEqual(null); + expect(log.componentCodeFrame).toEqual(undefined); }); - // Symbolicating while pending should not make more requests. - callback.mockClear(); - getLogBoxSymbolication().symbolicate.mockClear(); + it('increments LogBoxLog count', () => { + const log = getLogBoxLog(); - log.symbolicate(callback); - expect(callback).not.toBeCalled(); - expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); - }); + expect(log.count).toEqual(1); - it('updates when symbolication finishes', async () => { - const log = getLogBoxLog(); + log.incrementCount(); - const callback = jest.fn(); - log.symbolicate(callback); - expect(callback).toBeCalledTimes(1); - expect(callback).toBeCalledWith('PENDING'); - expect(getLogBoxSymbolication().symbolicate).toBeCalled(); - - await runMicrotasks(); - - expect(callback).toBeCalledTimes(2); - expect(callback).toBeCalledWith('COMPLETE'); - expect(log.symbolicated).toEqual({ - error: null, - stack: createStack(['S(A)', 'S(B)', 'S(C)']), - status: 'COMPLETE', + expect(log.count).toEqual(2); }); - // Do not symbolicate again. - callback.mockClear(); - getLogBoxSymbolication().symbolicate.mockClear(); + it('starts without a symbolicated stack', () => { + const log = getLogBoxLog(); - log.symbolicate(callback); - - await runMicrotasks(); - - expect(callback).toBeCalledTimes(0); - expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); - }); - - it('updates when symbolication fails', async () => { - const error = new Error('...'); - getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { - throw error; + expect(log.symbolicated).toEqual({ + error: null, + stack: null, + status: 'NONE', + }); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: null, + status: 'NONE', + }); }); - const log = getLogBoxLog(); + it('updates when symbolication is in progress', () => { + const log = getLogBoxLog(); - const callback = jest.fn(); - log.symbolicate(callback); - expect(callback).toBeCalledTimes(1); - expect(callback).toBeCalledWith('PENDING'); - expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + const callback = jest.fn(); + log.symbolicate(callback); - await runMicrotasks(); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(2); + expect(log.symbolicated).toEqual({ + error: null, + stack: null, + status: 'PENDING', + }); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: null, + status: 'PENDING', + }); - expect(callback).toBeCalledTimes(2); - expect(callback).toBeCalledWith('FAILED'); - expect(log.symbolicated).toEqual({ - error, - stack: null, - status: 'FAILED', + // Symbolicating while pending should not make more requests. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.symbolicate(callback); + expect(callback).not.toBeCalled(); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); }); - // Do not symbolicate again, retry if needed. - callback.mockClear(); - getLogBoxSymbolication().symbolicate.mockClear(); + it('updates when symbolication finishes', async () => { + const log = getLogBoxLog(); - log.symbolicate(callback); + const callback = jest.fn(); + log.symbolicate(callback); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(2); + callback.mockClear(); - await runMicrotasks(); + await runMicrotasks(); - expect(callback).toBeCalledTimes(0); - expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); - }); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('COMPLETE'); + expect(callback.mock.calls[1][0]).toBe('COMPLETE'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + expect(log.codeFrame).toBe(STACK_CODE_FRAME); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: createComponentStack(['C(A)', 'C(B)', 'C(C)']), + status: 'COMPLETE', + }); + expect(log.componentCodeFrame).toBe(COMPONENT_CODE_FRAME); - it('retry updates when symbolication is in progress', () => { - const log = getLogBoxLog(); + // Do not symbolicate again. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); - const callback = jest.fn(); - log.retrySymbolicate(callback); + log.symbolicate(callback); - expect(callback).toBeCalledTimes(1); - expect(callback).toBeCalledWith('PENDING'); - expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(1); - expect(log.symbolicated).toEqual({ - error: null, - stack: null, - status: 'PENDING', + await runMicrotasks(); + + expect(callback).toBeCalledTimes(0); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); }); - // Symbolicating while pending should not make more requests. - callback.mockClear(); - getLogBoxSymbolication().symbolicate.mockClear(); + it('updates when first symbolication fails', async () => { + const error = new Error('...'); + let count = 0; + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { + count += 1; + if (count === 1) { + throw error; + } + return mockSymbolicate(stack); + }); - log.symbolicate(callback); - expect(callback).not.toBeCalled(); - expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); - }); + const log = getLogBoxLog(); - it('retry updates when symbolication finishes', async () => { - const log = getLogBoxLog(); + const callback = jest.fn(); + log.symbolicate(callback); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + callback.mockClear(); - const callback = jest.fn(); - log.retrySymbolicate(callback); - expect(callback).toBeCalledTimes(1); - expect(callback).toBeCalledWith('PENDING'); - expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + await runMicrotasks(); - await runMicrotasks(); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('FAILED'); + expect(callback.mock.calls[1][0]).toBe('COMPLETE'); + expect(log.symbolicated).toEqual({ + error, + stack: null, + status: 'FAILED', + }); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: createComponentStack(['C(A)', 'C(B)', 'C(C)']), + status: 'COMPLETE', + }); - expect(callback).toBeCalledTimes(2); - expect(callback).toBeCalledWith('COMPLETE'); - expect(log.symbolicated).toEqual({ - error: null, - stack: createStack(['S(A)', 'S(B)', 'S(C)']), - status: 'COMPLETE', + expect(log.componentCodeFrame).toBe(COMPONENT_CODE_FRAME); + + // Do not symbolicate again, retry if needed. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.symbolicate(callback); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(0); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); }); - // Do not symbolicate again - callback.mockClear(); - getLogBoxSymbolication().symbolicate.mockClear(); + it('updates when second symbolication fails', async () => { + const error = new Error('...'); + let count = 0; + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { + count += 1; + if (count === 2) { + throw error; + } + return mockSymbolicate(stack); + }); - log.retrySymbolicate(callback); - jest.runAllTicks(); + const log = getLogBoxLog(); - expect(callback).toBeCalledTimes(0); - expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); - }); + const callback = jest.fn(); + log.symbolicate(callback); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + callback.mockClear(); - it('retry updates when symbolication fails', async () => { - const error = new Error('...'); - getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { - throw error; + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('COMPLETE'); + expect(callback.mock.calls[1][0]).toBe('FAILED'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + expect(log.codeFrame).toBe(STACK_CODE_FRAME); + expect(log.symbolicatedComponentStack).toEqual({ + error, + componentStack: null, + status: 'FAILED', + }); + + // Do not symbolicate again, retry if needed. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.symbolicate(callback); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(0); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); }); - const log = getLogBoxLog(); + it('retry updates when symbolication is in progress', () => { + const log = getLogBoxLog(); - const callback = jest.fn(); - log.retrySymbolicate(callback); - expect(callback).toBeCalledTimes(1); - expect(callback).toBeCalledWith('PENDING'); - expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + const callback = jest.fn(); + log.retrySymbolicate(callback); - await runMicrotasks(); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(2); + expect(log.symbolicated).toEqual({ + error: null, + stack: null, + status: 'PENDING', + }); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: null, + status: 'PENDING', + }); - expect(callback).toBeCalledTimes(2); - expect(callback).toBeCalledWith('FAILED'); - expect(log.symbolicated).toEqual({ - error, - stack: null, - status: 'FAILED', + // Symbolicating while pending should not make more requests. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.symbolicate(callback); + expect(callback).not.toBeCalled(); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); }); - // Retry to symbolicate again. - callback.mockClear(); - getLogBoxSymbolication().symbolicate.mockClear(); - getLogBoxSymbolication().symbolicate.mockImplementation(async stack => ({ - stack: createStack(stack.map(frame => `S(${frame.methodName})`)), - codeFrame: null, - })); + it('retry updates when symbolication finishes', async () => { + const log = getLogBoxLog(); - log.retrySymbolicate(callback); + const callback = jest.fn(); + log.retrySymbolicate(callback); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(2); + callback.mockClear(); - expect(callback).toBeCalledTimes(1); - expect(callback).toBeCalledWith('PENDING'); - expect(getLogBoxSymbolication().symbolicate).toBeCalled(); + await runMicrotasks(); - await runMicrotasks(); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('COMPLETE'); + expect(callback.mock.calls[1][0]).toBe('COMPLETE'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + expect(log.codeFrame).toBe(STACK_CODE_FRAME); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: createComponentStack(['C(A)', 'C(B)', 'C(C)']), + status: 'COMPLETE', + }); + expect(log.componentCodeFrame).toBe(COMPONENT_CODE_FRAME); - expect(callback).toBeCalledTimes(2); - expect(callback).toBeCalledWith('COMPLETE'); - expect(log.symbolicated).toEqual({ - error: null, - stack: createStack(['S(A)', 'S(B)', 'S(C)']), - status: 'COMPLETE', + // Do not symbolicate again + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + + log.retrySymbolicate(callback); + jest.runAllTicks(); + + expect(callback).toBeCalledTimes(0); + expect(getLogBoxSymbolication().symbolicate).not.toBeCalled(); + }); + + it('retry updates when both symbolications fail', async () => { + const error = new Error('...'); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { + throw error; + }); + + const log = getLogBoxLog(); + + const callback = jest.fn(); + log.retrySymbolicate(callback); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(2); + callback.mockClear(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('FAILED'); + expect(callback.mock.calls[1][0]).toBe('FAILED'); + expect(log.symbolicated).toEqual({ + error, + stack: null, + status: 'FAILED', + }); + expect(log.symbolicatedComponentStack).toEqual({ + error, + componentStack: null, + status: 'FAILED', + }); + + // Retry to symbolicate again. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => + mockSymbolicate(stack), + ); + + log.retrySymbolicate(callback); + + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(2); + callback.mockClear(); + + await runMicrotasks(); + + expect(callback.mock.calls[0][0]).toBe('COMPLETE'); + expect(callback.mock.calls[1][0]).toBe('COMPLETE'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + expect(log.codeFrame).toBe(STACK_CODE_FRAME); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: createComponentStack(['C(A)', 'C(B)', 'C(C)']), + status: 'COMPLETE', + }); + expect(log.componentCodeFrame).toBe(COMPONENT_CODE_FRAME); + }); + + it('retry updates when stack symbolication fails', async () => { + const error = new Error('...'); + let count = 0; + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { + count += 1; + if (count === 1) { + throw error; + } + return mockSymbolicate(stack); + }); + + const log = getLogBoxLog(); + + const callback = jest.fn(); + log.retrySymbolicate(callback); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(2); + callback.mockClear(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('FAILED'); + expect(callback.mock.calls[1][0]).toBe('COMPLETE'); + expect(log.symbolicated).toEqual({ + error, + stack: null, + status: 'FAILED', + }); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: createComponentStack(['C(A)', 'C(B)', 'C(C)']), + status: 'COMPLETE', + }); + expect(log.componentCodeFrame).toBe(COMPONENT_CODE_FRAME); + + // Retry to symbolicate again. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => + mockSymbolicate(stack), + ); + + log.retrySymbolicate(callback); + + // Since only one symbolication failed, we should only have one pending. + expect(callback).toBeCalledTimes(1); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(1); + callback.mockClear(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(1); + expect(callback.mock.calls[0][0]).toBe('COMPLETE'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + expect(log.codeFrame).toBe(STACK_CODE_FRAME); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: createComponentStack(['C(A)', 'C(B)', 'C(C)']), + status: 'COMPLETE', + }); + expect(log.componentCodeFrame).toBe(COMPONENT_CODE_FRAME); + }); + + it('retry updates when component symbolication fails', async () => { + const error = new Error('...'); + let count = 0; + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { + count += 1; + if (count === 2) { + throw error; + } + return mockSymbolicate(stack); + }); + + const log = getLogBoxLog(); + + const callback = jest.fn(); + log.retrySymbolicate(callback); + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(callback.mock.calls[1][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(2); + callback.mockClear(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(2); + expect(callback.mock.calls[0][0]).toBe('COMPLETE'); + expect(callback.mock.calls[1][0]).toBe('FAILED'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + expect(log.codeFrame).toBe(STACK_CODE_FRAME); + expect(log.symbolicatedComponentStack).toEqual({ + error, + componentStack: null, + status: 'FAILED', + }); + + // Retry to symbolicate again. + callback.mockClear(); + getLogBoxSymbolication().symbolicate.mockClear(); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => + mockSymbolicate(stack), + ); + + log.retrySymbolicate(callback); + + // Since only one symbolication failed, we should only have one pending. + expect(callback).toBeCalledTimes(1); + expect(callback.mock.calls[0][0]).toBe('PENDING'); + expect(getLogBoxSymbolication().symbolicate).toBeCalledTimes(1); + callback.mockClear(); + + await runMicrotasks(); + + expect(callback).toBeCalledTimes(1); + expect(callback.mock.calls[0][0]).toBe('COMPLETE'); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + expect(log.codeFrame).toBe(STACK_CODE_FRAME); + expect(log.symbolicatedComponentStack).toEqual({ + error: null, + componentStack: createComponentStack(['C(A)', 'C(B)', 'C(C)']), + status: 'COMPLETE', + }); + expect(log.componentCodeFrame).toBe(COMPONENT_CODE_FRAME); }); }); }); diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorBody.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorBody.js index 65af7fb2cdd..f9059fa82d1 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorBody.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorBody.js @@ -52,7 +52,10 @@ export default function LogBoxInspectorBody(props: { title={headerTitle} /> - + @@ -68,7 +71,10 @@ export default function LogBoxInspectorBody(props: { level={props.log.level} title={headerTitle} /> - + diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorCodeFrame.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorCodeFrame.js index 8061eaace4a..0b4606cfd19 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorCodeFrame.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorCodeFrame.js @@ -22,16 +22,13 @@ import LogBoxButton from './LogBoxButton'; import LogBoxInspectorSection from './LogBoxInspectorSection'; import * as LogBoxStyle from './LogBoxStyle'; import * as React from 'react'; + type Props = $ReadOnly<{ + componentCodeFrame: ?CodeFrame, codeFrame: ?CodeFrame, }>; -function LogBoxInspectorCodeFrame(props: Props): React.Node { - const codeFrame = props.codeFrame; - if (codeFrame == null) { - return null; - } - +function CodeFrameDisplay({codeFrame}: {codeFrame: CodeFrame}): React.Node { function getFileName() { // $FlowFixMe[incompatible-use] const matches = /[^/]*$/.exec(codeFrame.fileName); @@ -56,30 +53,52 @@ function LogBoxInspectorCodeFrame(props: Props): React.Node { } return ( - }> - - - - - - - { - openFileInEditor(codeFrame.fileName, codeFrame.location?.row ?? 0); - }}> - - {getFileName()} - {getLocation()} - - + + + + + + { + openFileInEditor(codeFrame.fileName, codeFrame.location?.row ?? 0); + }}> + + {getFileName()} + {getLocation()} + + + + ); +} + +function LogBoxInspectorCodeFrame(props: Props): React.Node { + const {codeFrame, componentCodeFrame} = props; + let sources = []; + if (codeFrame != null) { + sources.push(codeFrame); + } + if ( + componentCodeFrame != null && + componentCodeFrame?.content !== codeFrame?.content + ) { + sources.push(componentCodeFrame); + } + if (sources.length === 0) { + return null; + } + return ( + 1 ? 'Sources' : 'Source'} + action={}> + {sources.map((frame, index) => ( + + ))} ); } diff --git a/packages/react-native/Libraries/LogBox/UI/__tests__/LogBoxInspectorCodeFrame-test.js b/packages/react-native/Libraries/LogBox/UI/__tests__/LogBoxInspectorCodeFrame-test.js index 302fb4508b0..a9a64308815 100644 --- a/packages/react-native/Libraries/LogBox/UI/__tests__/LogBoxInspectorCodeFrame-test.js +++ b/packages/react-native/Libraries/LogBox/UI/__tests__/LogBoxInspectorCodeFrame-test.js @@ -37,7 +37,7 @@ jest.mock('../LogBoxInspectorSection', () => ({ describe('LogBoxInspectorCodeFrame', () => { it('should render null for no code frame', async () => { const output = await render.create( - , + , ); expect(output).toMatchSnapshot(); @@ -46,6 +46,7 @@ describe('LogBoxInspectorCodeFrame', () => { it('should render a code frame', async () => { const output = await render.create( { expect(output).toMatchSnapshot(); }); + it('should render both a code frame and a component frame', async () => { + const output = await render.create( + 91 | return ; + | ^ + 92 | } + 93 | + 94 |`, + location: {row: 90, column: 10}, + fileName: '/path/to/RKJSModules/Apps/CrashReact/CrashReactApp.js', + }} + codeFrame={{ + fileName: '/path/to/RKJSModules/Apps/CrashReact/CrashReactApp.js', + location: {row: 64, column: 16}, + content: ` 62 | + 63 | function ConsoleWithThrow() { +> 64 | console.error('hit'); + | ^ + 65 | throw new Error('test'); + 66 | } + 67 |`, + }} + />, + ); + + expect(output).toMatchSnapshot(); + }); + + it('should dedupe if code frames are the same', async () => { + const output = await render.create( + 65 | throw new Error('test'); + | ^ + 66 | } + 67 | + 68 |`, + location: {row: 65, column: 18}, + fileName: '/path/to/RKJSModules/Apps/CrashReact/CrashReactApp.js', + }} + codeFrame={{ + content: ` 63 | function ConsoleWithThrow() { + 64 | console.error('hit'); +> 65 | throw new Error('test'); + | ^ + 66 | } + 67 | + 68 |`, + location: {row: 65, column: 18}, + fileName: '/path/to/RKJSModules/Apps/CrashReact/CrashReactApp.js', + }} + />, + ); + + expect(output).toMatchSnapshot(); + }); + it('should render a code frame without a location', async () => { const output = await render.create( } + heading="Source" +> + + + + + + + + + CrashReactApp.js + (65:19) + + + + +`; + exports[`LogBoxInspectorCodeFrame should render a code frame 1`] = ` } @@ -173,4 +262,174 @@ exports[`LogBoxInspectorCodeFrame should render a code frame without a location `; +exports[`LogBoxInspectorCodeFrame should render both a code frame and a component frame 1`] = ` +} + heading="Sources" +> + + + + + + + + + CrashReactApp.js + (64:17) + + + + + + + + + + + + CrashReactApp.js + (90:11) + + + + +`; + exports[`LogBoxInspectorCodeFrame should render null for no code frame 1`] = `null`; diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 970e247e3a8..cb26bad89d2 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -6064,6 +6064,7 @@ declare class LogBoxLog { count: number; level: LogLevel; codeFrame: ?CodeFrame; + componentCodeFrame: ?CodeFrame; isComponentError: boolean; extraData: mixed | void; symbolicated: @@ -6257,6 +6258,7 @@ exports[`public API should not change unintentionally Libraries/LogBox/UI/LogBox exports[`public API should not change unintentionally Libraries/LogBox/UI/LogBoxInspectorCodeFrame.js 1`] = ` "type Props = $ReadOnly<{ + componentCodeFrame: ?CodeFrame, codeFrame: ?CodeFrame, }>; declare function LogBoxInspectorCodeFrame(props: Props): React.Node;