diff --git a/CHANGELOG.md b/CHANGELOG.md index e64a69dd41..abfc17dbe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes +- Use React `componentStack` as fallback when error has no stack trace on Android ([#5965](https://github.com/getsentry/sentry-react-native/pull/5965) - Add `SENTRY_PROJECT_ROOT` env var to override project root in Xcode build phase scripts for monorepo setups ([#5961](https://github.com/getsentry/sentry-react-native/pull/5961)) ### Features diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts index ea12cd205a..35ff650ae3 100644 --- a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts +++ b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts @@ -170,6 +170,16 @@ function setupErrorUtilsGlobalHandler(): void { return; } + // React render errors may arrive without useful frames in .stack but with a + // .componentStack (set by ReactFiberErrorDialog) that contains component + // locations with bundle offsets. Use componentStack as a fallback so + // eventFromException can extract frames with source locations. + // oxlint-disable-next-line typescript-eslint(no-unsafe-member-access) + if (error?.componentStack && (!error.stack || !hasStackFrames(error.stack))) { + // oxlint-disable-next-line typescript-eslint(no-unsafe-member-access) + error.stack = `${error.message || 'Error'}${error.componentStack}`; + } + const hint: EventHint = { originalException: error, attachments: getCurrentScope().getScopeData().attachments, @@ -211,3 +221,10 @@ function setupErrorUtilsGlobalHandler(): void { ); }); } + +/** + * Checks if a stack trace string contains at least one frame line. + */ +function hasStackFrames(stack: unknown): boolean { + return typeof stack === 'string' && stack.includes('\n'); +} diff --git a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts index 9ab4d7dfe7..4611a6425b 100644 --- a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts +++ b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts @@ -140,6 +140,62 @@ describe('ReactNativeErrorHandlers', () => { expect(hint).toEqual(expect.objectContaining({ originalException: new Error('Test Error') })); }); + + test('Uses componentStack as fallback when error has no stack', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!(); + + const error: any = { + message: 'Value is undefined, expected an Object', + componentStack: + '\n at UserMessage (http://localhost:8081/index.bundle:1:5274251)' + + '\n at renderItem (http://localhost:8081/index.bundle:1:5280705)', + }; + + await errorHandlerCallback!(error, true); + await client.flush(); + + expect(error.stack).toBe( + 'Value is undefined, expected an Object' + + '\n at UserMessage (http://localhost:8081/index.bundle:1:5274251)' + + '\n at renderItem (http://localhost:8081/index.bundle:1:5280705)', + ); + }); + + test('Uses componentStack as fallback when stack has no frames', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!(); + + const error: any = { + message: 'Value is undefined, expected an Object', + stack: 'Error: Value is undefined, expected an Object', + componentStack: + '\n at UserMessage (http://localhost:8081/index.bundle:1:5274251)' + + '\n at renderItem (http://localhost:8081/index.bundle:1:5280705)', + }; + + await errorHandlerCallback!(error, true); + await client.flush(); + + expect(error.stack).toBe( + 'Value is undefined, expected an Object' + + '\n at UserMessage (http://localhost:8081/index.bundle:1:5274251)' + + '\n at renderItem (http://localhost:8081/index.bundle:1:5280705)', + ); + }); + + test('Does not override stack when error already has frames', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce!(); + + const error = new Error('Test Error'); + (error as any).componentStack = '\n at SomeComponent (http://localhost:8081/index.bundle:1:100)'; + const originalStack = error.stack; + + await errorHandlerCallback!(error, false); + + expect(error.stack).toBe(originalStack); + }); }); describe('onUnhandledRejection', () => {