Summary
On Android, the crash-time TurboModule attribution added in #6227 is permanently pinned to a single startup call. Every native crash report from an Android app carries:
turbo_module.name: RNSentry
turbo_module.method: initNativeReactNavigationNewFrameTracking
regardless of what was actually executing. The tags are not stale-by-a-moment; they are stuck for the entire process lifetime, so the feature reports a wrong answer rather than no answer. iOS is unaffected.
Cause
Three facts compose:
-
wrapTurboModule pops a tracker frame for a promise-returning method only when that promise settles (src/js/turbomodule/wrapTurboModule.ts — the isThenable(result) branch pops in both then handlers).
-
The Android implementation never settles the promise:
// android/src/main/java/io/sentry/react/RNSentryModuleImpl.java
public void initNativeReactNavigationNewFrameTracking(Promise promise) {
this.initFragmentInitialFrameTracking();
}
promise is accepted and dropped — no resolve, no reject, no path that settles it later. iOS does the equivalent work and then calls resolve(nil) (ios/RNSentry.mm).
-
RNSENTRY_SKIP in turboModuleContextIntegration does not include this method, so it is wrapped like any other.
The frame pushed at startup therefore never pops. popTurboModuleCall walks the stack and re-syncs the scope onto "the newest remaining frame on the same scope" after every subsequent pop — which, once the app is idle, is always the leaked startup frame. The native scope is left holding it, and sentry-java serialises it into every crash report.
Two smaller consequences of the same leak: the tracker stack never drains to empty (so clearScope's empty-string sentinel path is unreachable on Android), and the aggregate record for that call is never emitted.
Steps to reproduce
- Expo app on Android with
enableTimeToInitialDisplay on (expoRouterIntegration or reactNavigationIntegration).
- Let the app finish starting, navigate anywhere, idle.
- Trigger any native crash — e.g.
Sentry.nativeCrash() — or read getTurboModuleCallStack().
- The event's tags report
RNSentry.initNativeReactNavigationNewFrameTracking as the in-flight call; the stack still holds that frame.
Suggested fix
Settle the promise in the Android implementation, mirroring iOS:
public void initNativeReactNavigationNewFrameTracking(Promise promise) {
this.initFragmentInitialFrameTracking();
promise.resolve(null);
}
Adding the method to RNSENTRY_SKIP would hide the symptom but leave the dangling promise, so the native-side fix seems the right one. It may be worth auditing the other Promise-taking methods in RNSentryModuleImpl for the same shape.
Versions
@sentry/react-native: reproduced on 8.27.0; the Android code is unchanged on 8.29.0 (current latest)
- Platform: Android only (iOS resolves correctly)
Summary
On Android, the crash-time TurboModule attribution added in #6227 is permanently pinned to a single startup call. Every native crash report from an Android app carries:
regardless of what was actually executing. The tags are not stale-by-a-moment; they are stuck for the entire process lifetime, so the feature reports a wrong answer rather than no answer. iOS is unaffected.
Cause
Three facts compose:
wrapTurboModulepops a tracker frame for a promise-returning method only when that promise settles (src/js/turbomodule/wrapTurboModule.ts— theisThenable(result)branch pops in boththenhandlers).The Android implementation never settles the promise:
promiseis accepted and dropped — noresolve, noreject, no path that settles it later. iOS does the equivalent work and then callsresolve(nil)(ios/RNSentry.mm).RNSENTRY_SKIPinturboModuleContextIntegrationdoes not include this method, so it is wrapped like any other.The frame pushed at startup therefore never pops.
popTurboModuleCallwalks the stack and re-syncs the scope onto "the newest remaining frame on the same scope" after every subsequent pop — which, once the app is idle, is always the leaked startup frame. The native scope is left holding it, and sentry-java serialises it into every crash report.Two smaller consequences of the same leak: the tracker stack never drains to empty (so
clearScope's empty-string sentinel path is unreachable on Android), and the aggregate record for that call is never emitted.Steps to reproduce
enableTimeToInitialDisplayon (expoRouterIntegrationorreactNavigationIntegration).Sentry.nativeCrash()— or readgetTurboModuleCallStack().RNSentry.initNativeReactNavigationNewFrameTrackingas the in-flight call; the stack still holds that frame.Suggested fix
Settle the promise in the Android implementation, mirroring iOS:
Adding the method to
RNSENTRY_SKIPwould hide the symptom but leave the dangling promise, so the native-side fix seems the right one. It may be worth auditing the otherPromise-taking methods inRNSentryModuleImplfor the same shape.Versions
@sentry/react-native: reproduced on 8.27.0; the Android code is unchanged on 8.29.0 (current latest)