Skip to content

Commit a688ab9

Browse files
authored
fix(android): Capture native exceptions consumed by Expo's bridgeless error handling (#5871)
* fix(android): Capture native exceptions swallowed by Expo's bridgeless error handling On Expo SDK 53+ Android, ExpoReactHostDelegate.handleInstanceException iterates registered ReactNativeHostHandlers but does not rethrow the exception. This means native crashes (e.g. IllegalStateException from Fabric's SurfaceMountingManager) caught by React Native's GuardedFrameCallback never reach Java's UncaughtExceptionHandler, which sentry-java relies on for crash capture. Register a ReactNativeHostHandler via Expo's Package system that intercepts these exceptions and captures them directly through Sentry.captureException with an unhandled mechanism (type="expoReactHost", handled=false) using ExceptionMechanismException. The Expo-specific code lives in a conditional src/expo source set that is only compiled when expo-modules-core is present as a Gradle project. Non-Expo React Native builds are completely unaffected. Expo's autolinking discovers SentryExpoPackage automatically via the new expo-module.config.json. * docs: Add changelog entry for Expo bridgeless exception capture * fix(android): Address PR review feedback for Expo exception handler - Wrap Sentry calls in try/catch to follow the project's defensive coding pattern ("never let Sentry crash the host app") - Scope changelog entry to Android - Add unit tests for SentryReactNativeHostHandler covering: - Skip capture in dev support mode - Skip capture when Sentry is not enabled - Capture with unhandled mechanism when enabled - Graceful handling when Sentry.captureException throws - Add expo-stubs.jar to test project for compile-time interfaces * Expo stubs in a separate project; fixes * Fixes * Fix
1 parent 99d85ec commit a688ab9

File tree

19 files changed

+579
-1
lines changed

19 files changed

+579
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
### Fixes
1212

13+
- Capture native exceptions consumed by Expo's bridgeless error handling on Android ([#5871](https://github.com/getsentry/sentry-react-native/pull/5871))
1314
- 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))
1415
- 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))
1516
- Fix duplicated breadcrumbs on Android ([#5841](https://github.com/getsentry/sentry-react-native/pull/5841))

packages/core/.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@
3737
!/expo.d.ts
3838
!/app.plugin.js
3939
!/plugin/build/**/*
40+
!/expo-module.config.json

packages/core/RNSentryAndroidTester/app/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ android {
3737
}
3838
}
3939

40+
android.sourceSets.main.java.srcDirs += ['../../android/src/expo/java']
41+
4042
dependencies {
4143
implementation project(':RNSentry')
4244
implementation 'com.facebook.react:react-android:0.72.0'
45+
implementation files('../../android/libs/expo-stubs.jar')
4346
implementation 'androidx.core:core-ktx:1.7.0'
4447
implementation 'androidx.appcompat:appcompat:1.4.1'
4548
implementation 'com.google.android.material:material:1.5.0'
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package io.sentry.react.expo
2+
3+
import io.sentry.Sentry
4+
import io.sentry.exception.ExceptionMechanismException
5+
import org.junit.After
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertFalse
8+
import org.junit.Assert.assertNotNull
9+
import org.junit.Assert.assertTrue
10+
import org.junit.Test
11+
import org.junit.runner.RunWith
12+
import org.junit.runners.JUnit4
13+
import org.mockito.MockedStatic
14+
import org.mockito.Mockito.mockStatic
15+
import org.mockito.kotlin.any
16+
import org.mockito.kotlin.argumentCaptor
17+
import org.mockito.kotlin.never
18+
import org.mockito.kotlin.verify
19+
20+
@RunWith(JUnit4::class)
21+
class SentryReactNativeHostHandlerTest {
22+
private var sentryMock: MockedStatic<Sentry>? = null
23+
24+
@After
25+
fun tearDown() {
26+
sentryMock?.close()
27+
}
28+
29+
@Test
30+
fun `does not capture when in developer support mode`() {
31+
sentryMock =
32+
mockStatic(Sentry::class.java).also {
33+
it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true)
34+
}
35+
36+
val handler = SentryReactNativeHostHandler()
37+
handler.onReactInstanceException(true, RuntimeException("test"))
38+
39+
sentryMock!!.verify({ Sentry.captureException(any()) }, never())
40+
}
41+
42+
@Test
43+
fun `does not capture when sentry is not enabled`() {
44+
sentryMock =
45+
mockStatic(Sentry::class.java).also {
46+
it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(false)
47+
}
48+
49+
val handler = SentryReactNativeHostHandler()
50+
handler.onReactInstanceException(false, RuntimeException("test"))
51+
52+
sentryMock!!.verify({ Sentry.captureException(any()) }, never())
53+
}
54+
55+
@Test
56+
fun `captures exception with unhandled mechanism when sentry is enabled`() {
57+
sentryMock =
58+
mockStatic(Sentry::class.java).also {
59+
it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true)
60+
}
61+
62+
val handler = SentryReactNativeHostHandler()
63+
val originalException = IllegalStateException("Fabric crash")
64+
65+
handler.onReactInstanceException(false, originalException)
66+
67+
val captor = argumentCaptor<Throwable>()
68+
sentryMock!!.verify { Sentry.captureException(captor.capture()) }
69+
70+
val captured = captor.firstValue
71+
assertTrue(
72+
"Expected ExceptionMechanismException but got ${captured::class.java}",
73+
captured is ExceptionMechanismException,
74+
)
75+
76+
val mechanismException = captured as ExceptionMechanismException
77+
val mechanism = mechanismException.exceptionMechanism
78+
assertEquals("expoReactHost", mechanism.type)
79+
assertFalse("Mechanism should be unhandled", mechanism.isHandled!!)
80+
assertEquals(originalException, mechanismException.throwable)
81+
assertNotNull(mechanismException.thread)
82+
}
83+
84+
@Test
85+
fun `does not throw when sentry capture fails`() {
86+
sentryMock =
87+
mockStatic(Sentry::class.java).also {
88+
it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true)
89+
it.`when`<Any> { Sentry.captureException(any()) }.thenThrow(RuntimeException("Sentry internal error"))
90+
}
91+
92+
val handler = SentryReactNativeHostHandler()
93+
// Should not throw
94+
handler.onReactInstanceException(false, IllegalStateException("test"))
95+
}
96+
}

packages/core/android/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,14 @@ android {
4848
} else {
4949
java.srcDirs += ['src/oldarch']
5050
}
51+
java.srcDirs += ['src/expo']
5152
}
5253
}
5354
}
5455

5556
dependencies {
5657
compileOnly files('libs/replay-stubs.jar')
58+
compileOnly files('libs/expo-stubs.jar')
5759
implementation 'com.facebook.react:react-native:+'
5860
api 'io.sentry:sentry-android:8.36.0'
5961
debugImplementation 'io.sentry:sentry-spotlight:8.36.0'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
This module provides stubs for `expo-modules-core` interfaces (`Package` and `ReactNativeHostHandler`) needed to compile the Expo-specific source set (`android/src/expo/`).
2+
3+
The Expo source set registers a `ReactNativeHostHandler` that captures native exceptions swallowed by Expo's bridgeless error handling (`ExpoReactHostDelegate.handleInstanceException`). These stubs are added as a `compileOnly` dependency to `android/build.gradle` (meaning, they are not present at runtime). In Expo projects, the real `expo-modules-core` classes are available at runtime via Expo's autolinking.
4+
5+
## Updating the stubs
6+
7+
To update the stubs, just run `yarn build` from the root of the repo and it will recompile the classes and put them under `packages/core/android/libs/expo-stubs.jar`. Check this newly generated `.jar` in and push.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
allprojects {
2+
repositories {
3+
mavenCentral()
4+
google()
5+
}
6+
}
7+
8+
apply plugin: 'java-library'
9+
10+
java {
11+
sourceCompatibility = JavaVersion.VERSION_1_8
12+
targetCompatibility = JavaVersion.VERSION_1_8
13+
}
14+
15+
tasks.named('jar', Jar) {
16+
archiveBaseName.set('expo-stubs')
17+
archiveVersion.set('')
18+
destinationDirectory.set(file("$rootDir/../libs"))
19+
}
20+
21+
dependencies {
22+
compileOnly 'com.google.android:android:4.1.1.4'
23+
}
Binary file not shown.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
distributionBase=GRADLE_USER_HOME
2+
distributionPath=wrapper/dists
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
4+
networkTimeout=10000
5+
validateDistributionUrl=true
6+
zipStoreBase=GRADLE_USER_HOME
7+
zipStorePath=wrapper/dists

0 commit comments

Comments
 (0)