Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### Fixes

- Capture native exceptions consumed by Expo's bridgeless error handling on Android ([#5871](https://github.com/getsentry/sentry-react-native/pull/5871))
- Fix SIGABRT crash on launch when `mobileReplayIntegration` is not configured and iOS deployment target >= 16.0 ([#5858](https://github.com/getsentry/sentry-react-native/pull/5858))
- Reduce `reactNavigationIntegration` performance overhead ([#5840](https://github.com/getsentry/sentry-react-native/pull/5840), [#5842](https://github.com/getsentry/sentry-react-native/pull/5842), [#5849](https://github.com/getsentry/sentry-react-native/pull/5849))
- Fix duplicated breadcrumbs on Android ([#5841](https://github.com/getsentry/sentry-react-native/pull/5841))
Expand Down
1 change: 1 addition & 0 deletions packages/core/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@
!/expo.d.ts
!/app.plugin.js
!/plugin/build/**/*
!/expo-module.config.json
3 changes: 3 additions & 0 deletions packages/core/RNSentryAndroidTester/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ android {
}
}

android.sourceSets.main.java.srcDirs += ['../../android/src/expo/java']

dependencies {
implementation project(':RNSentry')
implementation 'com.facebook.react:react-android:0.72.0'
implementation files('libs/expo-stubs.jar')
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
Expand Down
Binary file not shown.
Comment thread
alwx marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package io.sentry.react.expo

import io.sentry.Sentry
import io.sentry.exception.ExceptionMechanismException
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.mockito.MockedStatic
import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.never
import org.mockito.kotlin.verify

@RunWith(JUnit4::class)
class SentryReactNativeHostHandlerTest {

private var sentryMock: MockedStatic<Sentry>? = null

@After
fun tearDown() {
sentryMock?.close()
}

@Test
fun `does not capture when in developer support mode`() {
sentryMock = mockStatic(Sentry::class.java).also {
it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true)
}

val handler = SentryReactNativeHostHandler()
handler.onReactInstanceException(true, RuntimeException("test"))

sentryMock!!.verify({ Sentry.captureException(any()) }, never())
}

@Test
fun `does not capture when sentry is not enabled`() {
sentryMock = mockStatic(Sentry::class.java).also {
it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(false)
}

val handler = SentryReactNativeHostHandler()
handler.onReactInstanceException(false, RuntimeException("test"))

sentryMock!!.verify({ Sentry.captureException(any()) }, never())
}

@Test
fun `captures exception with unhandled mechanism when sentry is enabled`() {
sentryMock = mockStatic(Sentry::class.java).also {
it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true)
}

val handler = SentryReactNativeHostHandler()
val originalException = IllegalStateException("Fabric crash")

handler.onReactInstanceException(false, originalException)

val captor = argumentCaptor<Throwable>()
sentryMock!!.verify { Sentry.captureException(captor.capture()) }

val captured = captor.firstValue
assertTrue(
"Expected ExceptionMechanismException but got ${captured::class.java}",
captured is ExceptionMechanismException,
)

val mechanismException = captured as ExceptionMechanismException
val mechanism = mechanismException.exceptionMechanism
assertEquals("expoReactHost", mechanism.type)
assertFalse("Mechanism should be unhandled", mechanism.isHandled!!)
assertEquals(originalException, mechanismException.throwable)
assertNotNull(mechanismException.thread)
}

@Test
fun `does not throw when sentry capture fails`() {
sentryMock = mockStatic(Sentry::class.java).also {
it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true)
it.`when`<Any> { Sentry.captureException(any()) }.thenThrow(RuntimeException("Sentry internal error"))
}

val handler = SentryReactNativeHostHandler()
// Should not throw
handler.onReactInstanceException(false, IllegalStateException("test"))
}
}
12 changes: 12 additions & 0 deletions packages/core/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ android {
} else {
java.srcDirs += ['src/oldarch']
}
if (findProject(':expo-modules-core') != null) {
java.srcDirs += ['src/expo']
}
}
}
}
Expand All @@ -57,4 +60,13 @@ dependencies {
implementation 'com.facebook.react:react-native:+'
api 'io.sentry:sentry-android:8.36.0'
debugImplementation 'io.sentry:sentry-spotlight:8.36.0'

// Optional: Used only in Expo projects for capturing native exceptions
// swallowed by Expo's bridgeless error handling (ExpoReactHostDelegate).
// This is compileOnly so it doesn't pull expo-modules-core into non-Expo projects.
// The classes are only loaded when Expo's autolinking discovers SentryExpoPackage.
def expoModulesCore = findProject(':expo-modules-core')
if (expoModulesCore != null) {
compileOnly project(':expo-modules-core')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.sentry.react.expo;

import android.content.Context;
import expo.modules.core.interfaces.Package;
import expo.modules.core.interfaces.ReactNativeHostHandler;
import java.util.Collections;
import java.util.List;

/**
* Expo package that registers {@link SentryReactNativeHostHandler} to capture native exceptions
* swallowed by Expo's bridgeless error handling.
*
* <p>This package is auto-discovered by Expo's autolinking system when {@code @sentry/react-native}
* is installed in an Expo project. It is not used in non-Expo React Native projects.
*/
public class SentryExpoPackage implements Package {

@Override
public List<? extends ReactNativeHostHandler> createReactNativeHostHandlers(Context context) {
return Collections.singletonList(new SentryReactNativeHostHandler());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.sentry.react.expo;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be able to add some tests for this file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added unit tests in the latest commit (9567e56). Tests cover:

  • Skip capture in dev support mode
  • Skip capture when Sentry is not enabled
  • Capture with correct unhandled mechanism (type="expoReactHost", handled=false)
  • Graceful handling when Sentry.captureException throws (the new try/catch)

Created an expo-stubs.jar with minimal Package and ReactNativeHostHandler interfaces for the test project (following the existing replay-stubs.jar pattern).

Copy link
Copy Markdown
Contributor

@antonis antonis Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Wdyt of adding a short readme with the context on what is the jar file and how to regenerate it if needed (similar to replay-stubs)?

Copy link
Copy Markdown
Contributor Author

@alwx alwx Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't even know there is a document descrbing replay-stubs! :) Will check it out.


import androidx.annotation.NonNull;
import expo.modules.core.interfaces.ReactNativeHostHandler;
import io.sentry.Sentry;
import io.sentry.exception.ExceptionMechanismException;
import io.sentry.protocol.Mechanism;

/**
* Captures native Android exceptions that are routed through Expo's {@code
* ExpoReactHostDelegate.handleInstanceException}.
*
* <p>On Expo SDK 53+, certain native exceptions (e.g., IllegalStateException from Fabric's
* SurfaceMountingManager) are caught by React Native and routed to {@code handleInstanceException}.
* Expo's implementation iterates registered host handlers but does not rethrow, so the exception
* never reaches Java's {@code UncaughtExceptionHandler} which Sentry relies on for crash capture.
*
* <p>This handler captures those exceptions directly via {@code Sentry.captureException} with an
* unhandled mechanism, ensuring they appear as crashes in Sentry.
*/
public class SentryReactNativeHostHandler implements ReactNativeHostHandler {

private static final String MECHANISM_TYPE = "expoReactHost";

@Override
public void onReactInstanceException(boolean useDeveloperSupport, @NonNull Exception exception) {
if (useDeveloperSupport) {
return;
}

if (!Sentry.isEnabled()) {
return;
}

try { // NOPMD - We don't want to crash in any case
final Mechanism mechanism = new Mechanism();
mechanism.setType(MECHANISM_TYPE);
mechanism.setHandled(false);

final ExceptionMechanismException mechanismException =
new ExceptionMechanismException(mechanism, exception, Thread.currentThread());

Sentry.captureException(mechanismException);
} catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
// ignore
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions packages/core/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"platforms": ["android"]
}
Loading