diff --git a/CHANGELOG.md b/CHANGELOG.md
index e64a69dd41..fda3b0a184 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@
### Fixes
+- Recover missing JS stack traces from native `JavascriptException` on Android ([#5964](https://github.com/getsentry/sentry-react-native/pull/5964))
- Lazy-load Metro internal modules to prevent Expo 55 import errors ([#5958](https://github.com/getsentry/sentry-react-native/pull/5958))
### Dependencies
diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt
index bfa1647cbc..305e42459f 100644
--- a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt
+++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt
@@ -176,7 +176,7 @@ class RNSentrySDKTest {
}
private fun verifyDefaults(actualOptions: SentryAndroidOptions) {
- assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
+ assertFalse(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name)
assertEquals(
io.sentry.android.core.BuildConfig.VERSION_NAME,
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt
index c60776c942..aa883f2264 100644
--- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt
@@ -4,6 +4,7 @@ import android.app.Activity
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.common.JavascriptException
import io.sentry.Breadcrumb
+import io.sentry.Hint
import io.sentry.ILogger
import io.sentry.SentryEvent
import io.sentry.android.core.CurrentActivityHolder
@@ -32,6 +33,7 @@ class RNSentryStartTest {
MockitoAnnotations.openMocks(this)
logger = mock(ILogger::class.java)
activity = mock(Activity::class.java)
+ RNSentryJavascriptExceptionCache.clear()
}
@Test
@@ -196,10 +198,37 @@ class RNSentryStartTest {
}
@Test
- fun `the JavascriptException is added to the ignoredExceptionsForType list on with react defaults`() {
+ fun `the JavascriptException is not added to the ignoredExceptionsForType list`() {
val actualOptions = SentryAndroidOptions()
RNSentryStart.updateWithReactDefaults(actualOptions, activity)
- assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
+ assertFalse(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
+ }
+
+ @Test
+ fun `beforeSend caches JavascriptException stack and drops the event`() {
+ val options = SentryAndroidOptions()
+ RNSentryStart.updateWithReactFinals(options)
+
+ val jsStackTrace = "TypeError: Cannot read property 'content' of undefined\n at UserMessage (index.android.bundle:1:5274251)"
+ val jsException = JavascriptException(jsStackTrace)
+ val event = SentryEvent(jsException)
+
+ val result = options.beforeSend?.execute(event, Hint())
+
+ assertNull("JavascriptException event should be dropped", result)
+ val cached = RNSentryJavascriptExceptionCache.getAndClear()
+ assertEquals(jsStackTrace, cached)
+ }
+
+ @Test
+ fun `beforeSend does not drop non-JavascriptException events`() {
+ val options = SentryAndroidOptions()
+ val event = SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") }
+
+ RNSentryStart.updateWithReactFinals(options)
+ val result = options.beforeSend?.execute(event, Hint())
+
+ assertNotNull("Non-JavascriptException event should not be dropped", result)
}
@Test
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryJavascriptExceptionCache.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryJavascriptExceptionCache.java
new file mode 100644
index 0000000000..7e170c5e81
--- /dev/null
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryJavascriptExceptionCache.java
@@ -0,0 +1,54 @@
+package io.sentry.react;
+
+import java.util.concurrent.atomic.AtomicReference;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Thread-safe cache for the last JavascriptException stack trace string.
+ *
+ *
When React Native throws a JavascriptException on Android, the native Sentry SDK intercepts it
+ * in beforeSend and caches the stack trace here. The JS side can then retrieve it to enrich error
+ * events that arrive without a stack trace.
+ */
+final class RNSentryJavascriptExceptionCache {
+
+ private static final long TTL_MS = 5000;
+
+ private static final AtomicReference cache = new AtomicReference<>(null);
+
+ private RNSentryJavascriptExceptionCache() {}
+
+ static void cache(@Nullable String jsStackTrace) {
+ if (jsStackTrace == null || jsStackTrace.isEmpty()) {
+ return;
+ }
+ cache.set(new CachedEntry(jsStackTrace, System.currentTimeMillis()));
+ }
+
+ @Nullable
+ static String getAndClear() {
+ CachedEntry entry = cache.getAndSet(null);
+ if (entry == null) {
+ return null;
+ }
+ if (System.currentTimeMillis() - entry.timestampMs > TTL_MS) {
+ return null;
+ }
+ return entry.jsStackTrace;
+ }
+
+ /** Clears the cache. Visible for testing. */
+ static void clear() {
+ cache.set(null);
+ }
+
+ private static final class CachedEntry {
+ final String jsStackTrace;
+ final long timestampMs;
+
+ CachedEntry(String jsStackTrace, long timestampMs) {
+ this.jsStackTrace = jsStackTrace;
+ this.timestampMs = timestampMs;
+ }
+ }
+}
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
index ec1e75607a..161deb5b42 100644
--- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
@@ -993,6 +993,10 @@ public String fetchNativePackageName() {
return packageInfo.packageName;
}
+ public String fetchCachedJavascriptExceptionStack() {
+ return RNSentryJavascriptExceptionCache.getAndClear();
+ }
+
public void getDataFromUri(String uri, Promise promise) {
try {
Uri contentUri = Uri.parse(uri);
diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java
index 77851bf83b..c91a94d47b 100644
--- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java
+++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java
@@ -297,10 +297,6 @@ static void updateWithReactDefaults(
options.setTracesSampleRate(null);
options.setTracesSampler(null);
- // React native internally throws a JavascriptException.
- // we want to ignore it on the native side to avoid sending it twice.
- options.addIgnoredExceptionForType(JavascriptException.class);
-
setCurrentActivity(currentActivity);
}
@@ -312,6 +308,15 @@ static void updateWithReactFinals(@NotNull SentryAndroidOptions options) {
BeforeSendCallback userBeforeSend = options.getBeforeSend();
options.setBeforeSend(
(event, hint) -> {
+ // React Native internally throws a JavascriptException when a JS error occurs.
+ // We cache its stack trace (which may contain frames missing from the JS error)
+ // and drop the native event to avoid sending duplicates.
+ Throwable throwable = event.getThrowable();
+ if (throwable instanceof JavascriptException) {
+ RNSentryJavascriptExceptionCache.cache(throwable.getMessage());
+ return null;
+ }
+
setEventOriginTag(event);
// Note: In Sentry Android SDK v7, native SDK packages/integrations are already
// included in the SDK version set during initialization, so no need to copy them here.
diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java
index 9215c09c36..dd912adcac 100644
--- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java
+++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java
@@ -173,6 +173,11 @@ public String fetchNativePackageName() {
return this.impl.fetchNativePackageName();
}
+ @Override
+ public String fetchCachedJavascriptExceptionStack() {
+ return this.impl.fetchCachedJavascriptExceptionStack();
+ }
+
@Override
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
// Not used on Android
diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java
index 85fdb97a35..9908801cba 100644
--- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java
+++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java
@@ -173,6 +173,11 @@ public String fetchNativePackageName() {
return this.impl.fetchNativePackageName();
}
+ @ReactMethod(isBlockingSynchronousMethod = true)
+ public String fetchCachedJavascriptExceptionStack() {
+ return this.impl.fetchCachedJavascriptExceptionStack();
+ }
+
@ReactMethod(isBlockingSynchronousMethod = true)
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
// Not used on Android
diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm
index a43557079c..a6ba069ddb 100644
--- a/packages/core/ios/RNSentry.mm
+++ b/packages/core/ios/RNSentry.mm
@@ -371,6 +371,12 @@ - (void)handleShakeDetected
return packageName;
}
+RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, fetchCachedJavascriptExceptionStack)
+{
+ // Android-only feature, iOS does not need this.
+ return nil;
+}
+
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(
NSDictionary *, fetchNativeStackFramesBy : (NSArray *)instructionsAddr)
{
diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts
index 0a4aff54b5..ceafebeb65 100644
--- a/packages/core/src/js/NativeRNSentry.ts
+++ b/packages/core/src/js/NativeRNSentry.ts
@@ -48,6 +48,7 @@ export interface Spec extends TurboModule {
error?: string;
};
fetchNativePackageName(): string | undefined | null;
+ fetchCachedJavascriptExceptionStack(): string | undefined | null;
fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null;
initNativeReactNavigationNewFrameTracking(): Promise;
captureReplay(isHardCrash: boolean): Promise;
diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts
index 30dd3a156e..c12354f9cb 100644
--- a/packages/core/src/js/integrations/default.ts
+++ b/packages/core/src/js/integrations/default.ts
@@ -33,6 +33,7 @@ import {
modulesLoaderIntegration,
nativeLinkedErrorsIntegration,
nativeReleaseIntegration,
+ nativeStackRecoveryIntegration,
primitiveTagIntegration,
reactNativeErrorHandlersIntegration,
reactNativeInfoIntegration,
@@ -62,6 +63,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
}),
);
integrations.push(nativeLinkedErrorsIntegration());
+ integrations.push(nativeStackRecoveryIntegration());
} else {
integrations.push(browserApiErrorsIntegration());
integrations.push(browserGlobalHandlersIntegration());
diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts
index d4e80f8ef6..b210240fbd 100644
--- a/packages/core/src/js/integrations/exports.ts
+++ b/packages/core/src/js/integrations/exports.ts
@@ -2,6 +2,7 @@ export { debugSymbolicatorIntegration } from './debugsymbolicator';
export { deviceContextIntegration } from './devicecontext';
export { reactNativeErrorHandlersIntegration } from './reactnativeerrorhandlers';
export { nativeLinkedErrorsIntegration } from './nativelinkederrors';
+export { nativeStackRecoveryIntegration } from './nativestackrecovery';
export { nativeReleaseIntegration } from './release';
export { eventOriginIntegration } from './eventorigin';
export { sdkInfoIntegration } from './sdkinfo';
diff --git a/packages/core/src/js/integrations/nativestackrecovery.ts b/packages/core/src/js/integrations/nativestackrecovery.ts
new file mode 100644
index 0000000000..b1dc383cec
--- /dev/null
+++ b/packages/core/src/js/integrations/nativestackrecovery.ts
@@ -0,0 +1,61 @@
+import type { Client, Event, EventHint, Integration } from '@sentry/core';
+
+import { debug, parseStackFrames } from '@sentry/core';
+import { Platform } from 'react-native';
+
+import { notWeb } from '../utils/environment';
+import { NATIVE } from '../wrapper';
+
+const INTEGRATION_NAME = 'NativeStackRecovery';
+
+/**
+ * Recovers missing JS stack traces from the native JavascriptException cache.
+ *
+ * On Android, when a React render error occurs with Hermes, the JS error may arrive
+ * at the global error handler without a stack trace. However, the same error is caught
+ * by React Native's native layer as a JavascriptException which contains the full
+ * JS stack trace. This integration fetches that cached stack and attaches it to the event.
+ */
+export const nativeStackRecoveryIntegration = (): Integration => {
+ return {
+ name: INTEGRATION_NAME,
+ setupOnce: () => {
+ /* noop */
+ },
+ preprocessEvent: (event: Event, hint: EventHint, client: Client): void => preprocessEvent(event, hint, client),
+ };
+};
+
+function isEnabled(): boolean {
+ return notWeb() && NATIVE.enableNative && Platform.OS === 'android';
+}
+
+function preprocessEvent(event: Event, _hint: EventHint, client: Client): void {
+ if (!isEnabled()) {
+ return;
+ }
+
+ const primaryException = event.exception?.values?.[0];
+ if (!primaryException) {
+ return;
+ }
+
+ if (primaryException.stacktrace?.frames && primaryException.stacktrace.frames.length > 0) {
+ return;
+ }
+
+ const cachedStack = NATIVE.fetchCachedJavascriptExceptionStack();
+ if (!cachedStack) {
+ return;
+ }
+
+ const parser = client.getOptions().stackParser;
+ const syntheticError = new Error();
+ syntheticError.stack = cachedStack;
+
+ const frames = parseStackFrames(parser, syntheticError);
+ if (frames.length > 0) {
+ primaryException.stacktrace = { frames };
+ debug.log(`[${INTEGRATION_NAME}] Recovered ${frames.length} frames from native JavascriptException cache`);
+ }
+}
diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts
index 6a71e0ff6f..f7a780823e 100644
--- a/packages/core/src/js/wrapper.ts
+++ b/packages/core/src/js/wrapper.ts
@@ -123,6 +123,12 @@ interface SentryNativeWrapper {
fetchNativePackageName(): string | null;
+ /**
+ * Fetches and clears the cached JavascriptException stack trace from the native side.
+ * Returns null if no cached stack is available or the cache has expired.
+ */
+ fetchCachedJavascriptExceptionStack(): string | null;
+
/**
* Fetches native stack frames and debug images for the instructions addresses.
*/
@@ -756,6 +762,17 @@ export const NATIVE: SentryNativeWrapper = {
return RNSentry.fetchNativePackageName() || null;
},
+ fetchCachedJavascriptExceptionStack(): string | null {
+ if (!this.enableNative) {
+ return null;
+ }
+ if (!this._isModuleLoaded(RNSentry)) {
+ return null;
+ }
+
+ return RNSentry.fetchCachedJavascriptExceptionStack() || null;
+ },
+
fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | null {
if (!this.enableNative) {
return null;
diff --git a/packages/core/test/integrations/integrationsexecutionorder.test.ts b/packages/core/test/integrations/integrationsexecutionorder.test.ts
index df04a9327c..266acf3031 100644
--- a/packages/core/test/integrations/integrationsexecutionorder.test.ts
+++ b/packages/core/test/integrations/integrationsexecutionorder.test.ts
@@ -43,6 +43,24 @@ describe('Integration execution order', () => {
expect(nativeLinkedErrors.preprocessEvent).toHaveBeenCalledBefore(rewriteFrames.processEvent!);
expect(rewriteFrames.processEvent!).toHaveBeenCalledBefore(debugSymbolicator.processEvent!);
});
+
+ it('NativeStackRecovery is before RewriteFrames', async () => {
+ // NativeStackRecovery has to process event before RewriteFrames
+ // otherwise recovered stack trace frames won't be rewritten
+
+ const client = createTestClient();
+ const { integrations } = client.getOptions();
+
+ const nativeStackRecovery = spyOnIntegrationById('NativeStackRecovery', integrations);
+ const rewriteFrames = spyOnIntegrationById('RewriteFrames', integrations);
+
+ client.init();
+
+ client.captureException(new Error('test'));
+ await client.flush();
+
+ expect(nativeStackRecovery.preprocessEvent).toHaveBeenCalledBefore(rewriteFrames.processEvent!);
+ });
});
describe('web', () => {
diff --git a/packages/core/test/integrations/nativestackrecovery.test.ts b/packages/core/test/integrations/nativestackrecovery.test.ts
new file mode 100644
index 0000000000..a16ab0ca14
--- /dev/null
+++ b/packages/core/test/integrations/nativestackrecovery.test.ts
@@ -0,0 +1,216 @@
+jest.mock('../../src/js/utils/environment');
+
+import type { Client, Event, EventHint } from '@sentry/core';
+
+import { defaultStackParser } from '@sentry/browser';
+import { Platform } from 'react-native';
+
+import { nativeStackRecoveryIntegration } from '../../src/js/integrations/nativestackrecovery';
+import { notWeb } from '../../src/js/utils/environment';
+import { NATIVE } from '../../src/js/wrapper';
+
+jest.mock('../../src/js/wrapper');
+
+function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint): Event {
+ const mockedClient = {
+ getOptions: () => ({ stackParser: defaultStackParser }),
+ } as unknown as Client;
+
+ const integration = nativeStackRecoveryIntegration();
+ integration.preprocessEvent!(mockedEvent, mockedHint, mockedClient);
+ return mockedEvent;
+}
+
+describe('NativeStackRecovery', () => {
+ let originalPlatformOS: typeof Platform.OS;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ originalPlatformOS = Platform.OS;
+ Platform.OS = 'android';
+ (notWeb as jest.Mock).mockReturnValue(true);
+ (NATIVE as any).enableNative = true;
+ });
+
+ afterEach(() => {
+ Platform.OS = originalPlatformOS;
+ });
+
+ it('does nothing when native is disabled', () => {
+ (NATIVE as any).enableNative = false;
+ (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue('Error: test\n at foo (file.js:1:1)');
+
+ const event = executeIntegrationFor(
+ {
+ exception: {
+ values: [{ type: 'Error', value: 'test' }],
+ },
+ },
+ {},
+ );
+
+ expect(NATIVE.fetchCachedJavascriptExceptionStack).not.toHaveBeenCalled();
+ expect(event.exception?.values?.[0].stacktrace).toBeUndefined();
+ });
+
+ it('does nothing on non-android platforms', () => {
+ Platform.OS = 'ios';
+ (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue('Error: test\n at foo (file.js:1:1)');
+
+ const event = executeIntegrationFor(
+ {
+ exception: {
+ values: [{ type: 'Error', value: 'test' }],
+ },
+ },
+ {},
+ );
+
+ expect(NATIVE.fetchCachedJavascriptExceptionStack).not.toHaveBeenCalled();
+ expect(event.exception?.values?.[0].stacktrace).toBeUndefined();
+ });
+
+ it('does nothing when the event already has stacktrace frames', () => {
+ const event = executeIntegrationFor(
+ {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'test',
+ stacktrace: {
+ frames: [
+ {
+ filename: 'app.js',
+ function: 'myFunc',
+ lineno: 10,
+ colno: 5,
+ in_app: true,
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {},
+ );
+
+ expect(NATIVE.fetchCachedJavascriptExceptionStack).not.toHaveBeenCalled();
+ expect(event.exception?.values?.[0].stacktrace?.frames).toHaveLength(1);
+ });
+
+ it('does nothing when no cached stack is available', () => {
+ (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(null);
+
+ const event = executeIntegrationFor(
+ {
+ exception: {
+ values: [{ type: 'Error', value: 'test' }],
+ },
+ },
+ {},
+ );
+
+ expect(NATIVE.fetchCachedJavascriptExceptionStack).toHaveBeenCalledTimes(1);
+ expect(event.exception?.values?.[0].stacktrace).toBeUndefined();
+ });
+
+ it('parses and attaches frames when cached stack is available and event has no frames', () => {
+ const cachedStack = [
+ 'Error: Value is undefined, expected an Object',
+ ' at UserMessage (http://localhost:8081/index.bundle:1:5274251)',
+ ' at renderItem (http://localhost:8081/index.bundle:1:5280705)',
+ ' at Container (http://localhost:8081/index.bundle:1:5288922)',
+ ].join('\n');
+
+ (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(cachedStack);
+
+ const event = executeIntegrationFor(
+ {
+ exception: {
+ values: [{ type: 'Error', value: 'Value is undefined, expected an Object' }],
+ },
+ },
+ {},
+ );
+
+ expect(event.exception?.values?.[0].stacktrace?.frames).toBeDefined();
+ expect(event.exception?.values?.[0].stacktrace!.frames!.length).toBeGreaterThan(0);
+ });
+
+ it('does nothing when cached stack cannot be parsed', () => {
+ (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue('not a valid stack trace');
+
+ const event = executeIntegrationFor(
+ {
+ exception: {
+ values: [{ type: 'Error', value: 'test' }],
+ },
+ },
+ {},
+ );
+
+ expect(event.exception?.values?.[0].stacktrace).toBeUndefined();
+ });
+
+ it('does nothing when event has no exception', () => {
+ (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue('Error: test\n at foo (file.js:1:1)');
+
+ executeIntegrationFor({}, {});
+
+ expect(NATIVE.fetchCachedJavascriptExceptionStack).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when exception has empty frames array', () => {
+ const cachedStack = ['Error: test', ' at foo (http://localhost:8081/index.bundle:1:100)'].join('\n');
+
+ (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(cachedStack);
+
+ const event = executeIntegrationFor(
+ {
+ exception: {
+ values: [
+ {
+ type: 'Error',
+ value: 'test',
+ stacktrace: { frames: [] },
+ },
+ ],
+ },
+ },
+ {},
+ );
+
+ expect(NATIVE.fetchCachedJavascriptExceptionStack).toHaveBeenCalledTimes(1);
+ expect(event.exception?.values?.[0].stacktrace?.frames!.length).toBeGreaterThan(0);
+ });
+
+ it('only patches the primary exception (values[0]) when multiple exception values exist', () => {
+ const cachedStack = ['Error: test', ' at foo (http://localhost:8081/index.bundle:1:100)'].join('\n');
+
+ (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(cachedStack);
+
+ const event = executeIntegrationFor(
+ {
+ exception: {
+ values: [
+ { type: 'Error', value: 'primary error without stack' },
+ {
+ type: 'Error',
+ value: 'linked cause',
+ stacktrace: {
+ frames: [{ filename: 'cause.js', function: 'causeFn', lineno: 5, colno: 1, in_app: true }],
+ },
+ },
+ ],
+ },
+ },
+ {},
+ );
+
+ expect(event.exception?.values?.[0].stacktrace?.frames!.length).toBeGreaterThan(0);
+ expect(event.exception?.values?.[1].stacktrace?.frames).toHaveLength(1);
+ expect(event.exception?.values?.[1].stacktrace?.frames![0].filename).toBe('cause.js');
+ });
+});
diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts
index afa8c6d1fb..1eed7300f9 100644
--- a/packages/core/test/mockWrapper.ts
+++ b/packages/core/test/mockWrapper.ts
@@ -55,6 +55,7 @@ const NATIVE: MockInterface = {
stopProfiling: jest.fn(),
fetchNativePackageName: jest.fn(),
+ fetchCachedJavascriptExceptionStack: jest.fn(),
fetchNativeStackFramesBy: jest.fn(),
initNativeReactNavigationNewFrameTracking: jest.fn(),