You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
App version(s): 9.4.88-0 to 9.5.12-1, spread across builds (1 to 3 users each)
Affected route(s) (parameterized): none. The crash happens while the JS bundle loads, before any screen renders.
Mechanism: TombstoneMerged (SIGABRT), level fatal
Stack trace (top frames, first-party only)
com.facebook.react.common.JavascriptException: [runtime not ready]: Error: Cannot find native module 'ExpoAudio'
requireNativeModule
metroRequire chain (module initialization while the bundle loads)
global
facebook::react::JsErrorHandler::handleErrorWithCppPipeline (JsErrorHandler.cpp:403)
facebook::react::ReactInstance::loadScript lambda (ReactInstance.cpp:268)
std::terminate -> abort (SIGABRT)
Suspected cause
src/libs/Sound/index.native.ts imports expo-audio and calls setAudioModeAsync when the module loads. Startup code imports it (for example src/libs/actions/App.ts), so the bundle needs the ExpoAudio native module during its first evaluation. On some HybridApp Android launches that module is not registered, so requireNativeModule throws before the runtime is ready and the app aborts.
Reproduction
Unknown. See the Sentry events linked from the Sentry issue.
Please re-state the problem that we are trying to solve in this issue.
On Android HybridApp builds, the app crashes instantly on startup with a fatal Cannot find native module 'ExpoAudio' error because the audio module is being invoked before the native runtime is fully ready.
What is the root cause of that problem?
The root cause is a synchronous, top-level execution of a native module method during the initial JS bundle evaluation.
In src/libs/Sound/index.native.ts, expo-audio (or expo-av) is imported and configured (via setAudioModeAsync) at the top-level scope of the file.
Because Sound is imported early in the app lifecycle (e.g., through src/libs/actions/App.ts), the JS engine evaluates this file synchronously during bundle load.
In the Android HybridApp environment, native module registration timings differ from standalone React Native apps. Accessing the ExpoAudio native module at the top level throws a fatal JavascriptException because requireNativeModule fires before the HybridApp has finished registering it, causing a SIGABRT startup abort.
What changes do you think we should make in order to solve the problem?
We need to defer the native audio initialization so it does not execute during the top-level bundle evaluation. We will lazily configure the audio mode only when a sound is actually about to be played, wrapped in a safety catch for the HybridApp context.
// src/libs/Sound/index.native.ts// ... existing imports ...letisAudioConfigured=false;functionconfigureAudio(){if(isAudioConfigured){return;}try{Audio.setAudioModeAsync({playsInSilentModeIOS: true,// ... existing configuration ...});isAudioConfigured=true;}catch(error){Log.hmmm('Failed to initialize ExpoAudio in HybridApp',{error});}}exportdefaultfunctionplaySound(soundFile){configureAudio();// Defer native module access until runtime// ... existing play logic ...}
What alternative solutions did you explore? (Optional)
A purely native alternative would be patching the Android HybridApp host code to ensure the ExpoAudio package is forced into the synchronous native PackageList before the JS engine boots. This was rejected because React Native best practices dictate that JS should never assume native modules are synchronously available at bundle-load time. Lazy-loading the initialization in JS is much safer, prevents brittle startup crashes, and keeps the fix entirely within the cross-platform codebase.
Please re-state the problem that we are trying to solve in this issue.
HybridApp on Android crashes during startup with a fatal SIGABRT because the JS bundle fails during evaluation with Cannot find native module 'ExpoAudio'.
What is the root cause of that problem?
In src/libs/Sound/index.native.ts, line 9 statically imports createAudioPlayer and setAudioModeAsync from expo-audio, while line 25 immediately calls setAudioModeAsync during top-level bundle evaluation. The entry point of expo-audio evaluates AudioModule, which directly executes requireNativeModule('ExpoAudio'). Because startup code like src/libs/actions/App.ts imports src/libs/Sound, this executes before the native module registry is available in HybridApp Android, causing an unhandled synchronous exception.
What changes do you think we should make in order to solve the problem?
In src/libs/Sound/index.native.ts, retain only the type-only import import type {AudioSource} from 'expo-audio' at the module root, removing the value import of createAudioPlayer and setAudioModeAsync.
Remove the top-level evaluation of setAudioModeAsync at line 25.
Keep the existing guard order in playSound: check getIsMuted(), then verify SOUND_ASSETS[soundFile].
Inside playSound, dynamically require expo-audio wrapped in a try/catch block. If the native module cannot be resolved or loaded, log a warning and return early without throwing.
In the same lazy execution path, configure audio mode on first use via setAudioModeAsync({playsInSilentMode: false, shouldPlayInBackground: false, interruptionMode: 'mixWithOthers'}). If configuration fails, catch the error, reset any memoized state to avoid poisoning future attempts, and return before creating the audio player.
Guard player creation and playback with try/catch to ensure sound failures fail silently without crashing the app.
What alternative solutions did you explore? (Optional)
Earlier proposals failed by only moving setAudioModeAsync into a lazy helper while leaving the static value import import {createAudioPlayer, setAudioModeAsync} from 'expo-audio' at module scope. This still crashes on bundle load because expo-audio executes requireNativeModule('ExpoAudio') immediately upon being imported. Deferring the runtime require entirely prevents the startup abort. It fails safely.
src/libs/Sound/index.native.ts statically imports expo-audio and also calls setAudioModeAsync at module top level. expo-audio runs requireNativeModule('ExpoAudio') as soon as it is evaluated. @libs/Sound is imported by startup code such as src/libs/actions/App.ts and Session/index.ts, so that native lookup happens during the first bundle evaluation. If the native module isn't registered yet in a HybridApp launch, the error is thrown with no handler and the app aborts. Sound is not needed to start the app, but it can currently crash it.
What changes do you think we should make in order to solve the problem?
In src/libs/Sound/index.native.ts, remove the runtime import of expo-audio (keep import type only) and remove the top-level setAudioModeAsync call.
Add a small lazy loader that runs on first play. It loads expo-audio with a dynamic import() inside try/catch, caches the result, and calls setAudioModeAsync once. The cached promise replaces audioModeConfigured.
In playSound, call await loadAudio(). If it returns null, return silently, and log it with Log.warn. Otherwise use createAudioPlayer from the loaded module.
The existing getIsMuted check and the withMinimalExecutionTime wrapper stay as they are.
Add a unit test in tests/unit that mocks expo-audio to throw on load. It checks that importing @libs/Sound does not throw and that playSound resolves without error. A second test checks that audio mode is configured once, before the first player is created.
What alternative solutions did you explore? (Optional)
Wrap the top-level import in try/catch with require. This works, but it still does native lookups at startup and it is harder to type than a lazy loader.
Fix the native HybridApp module registration. That is probably the deeper reason the module goes missing, but it can't be reproduced here. The JS side should not crash the app over a notification sound either way.
Please re-state the problem that we are trying to solve in this issue.
HybridApp on Android crashes during application startup with a fatal SIGABRT / Tombstone crash: com.facebook.react.common.JavascriptException: [runtime not ready]: Error: Cannot find native module 'ExpoAudio'
thrown while the JS bundle is being evaluated during ReactInstance::loadScript.
What is the root cause of that problem?
In src/libs/Sound/index.native.ts, line 9 statically imports createAudioPlayer, setAudioModeAsync from expo-audio:
Core startup modules, including src/libs/actions/App.ts and src/libs/actions/Session/index.ts, import {clearSoundAssetsCache} from '@libs/Sound'. Therefore, Sound/index.native.ts is evaluated immediately when Metro loads and runs the initial JS bundle.
During the initial script evaluation on Android HybridApp launches, requireNativeModule('ExpoAudio') is invoked synchronously before the native JSI/modules runtime is fully ready and registered.
Because the error occurs at module top-level during bundle evaluation (ReactInstance::loadScript), it cannot be caught by any React ErrorBoundary, triggering facebook::react::JsErrorHandler::handleErrorWithCppPipeline and terminating the process via std::terminate -> abort() (SIGABRT).
What alternative solutions did you explore? (Optional)
Calling setAudioModeAsync inside an App.ts lifecycle hook was explored, but static imports of expo-audio still trigger module-level initialization code in expo-audio/src/index.ts. Lazy dynamic import (import('expo-audio')) on demand when sound is actually requested completely isolates startup from audio native module readiness.
What changes do you think we should make in order to solve the problem?
In src/libs/Sound/index.native.ts:
Remove the static import of expo-audio (createAudioPlayer, setAudioModeAsync).
@mountiny Reviewing today. Do I ask Melvin for a formal proposal once I verify the issue ? Asking since the above proposals which came in before Help Wanted label was added on the issue, guidelines say:
CONTRIBUTING.md - proposals posted before Help Wanted is added are ignored (Melvin excepted)
The issue's suspected cause is incomplete. ExpoAudio isn't missing in some builds. The JS bundle is being evaluated after the React instance it belongs to has gone away, and ExpoAudio is just the first native module the bundle touches at module scope (App.ts → @libs/Sound → setAudioModeAsync).
⚠️ All four proposals only lazy-load expo-audio. That would move the crash to the next native module rather than fix it, and the same Sentry group already shows that happening.
What Sentry shows (APP-MD4, 33 events, 32 users)
Not only ExpoAudio. Within the same group, all thrown in ReactInstance::loadScript with [runtime not ready]:
about 25 events: Cannot find native module 'ExpoAudio'
3 events: Exception in HostFunction: null cannot be cast to non-null type com.facebook.react.fabric.FabricUIManager, thrown from NativeReanimatedModule (Reanimated, nothing to do with Sound)
3 events: Exception in HostFunction: Required value was null
2 events from adhoc emulator builds (FETCH_PREVIEW_ENABLED flag mismatch), which are noise
APP-MD7 is the same crash on 9.4.63 (first seen 2026-09-02, before this issue's first-seen date). It isn't a recent regression.
Every event is a cold start. SoLoader initializes 2 to 8 seconds before the crash, with one event per user, spread evenly across devices, Android versions and releases. That looks like a timing race.
Breadcrumbs are almost identical across events. The OldDot ExpensifyActivityBase is created and resumed, yaplHideBootSplash fires twice, then the Expo log Cannot initialize app loader. RNHeadlessAppLoader appears and the crash follows.
No NewDot breadcrumbs. There's no openNewApp, ReactNativeFragment.onCreate or closeReactNativeFragment in any event I sampled. One event (9.5.12-1) had no Activity at all, so the process started in the background.
In ReactHostImpl.kt, reactInstance is assigned before loadJSBundle(). It's only set back to null in the reload task (L1406) and the destroy task (L1570).
BridgelessReactContext.hasActiveReactInstance() and ReactHostImpl.uiManager both depend on reactInstance != null.
Expo's ExpoBridgeModule.installModules() waits up to 1s for hasActiveReactInstance() (its own comment admits this race). If the instance never shows up, globalThis.expo is never installed and requireNativeModule throws.
Reanimated gets a nullFabricUIManager for the same reason.
So both crash flavours mean the ReactHost was destroyed or reloaded while the bundle was still evaluating. In HybridApp the only reactHost.destroy() caller is ReactNativeFragment.onDestroy, when shouldDestroyReactHostOnClose is set by closeReactNativeFragment(). That's reached from closeReactNativeApp, yaplCloseNewApp and DeepLinkManager.java:283.
A close or reopen of NewDot during the slow cold-start bundle load fits the timing. The missing NewDot breadcrumbs plus the background-start event also leave a headless start as a possibility (Airship's AirshipHeadlessEventService is the only other code that starts the ReactHost). A repro should confirm which trigger it is.
Fix direction
Native, in Mobile-Expensify (the real fix): make destroy() and start() safe against each other.
Don't call reactHost.destroy() while the instance is still loading. Defer it until onReactContextInitialized.
In openNewApp, wait for any in-flight destroy task to finish before adding a new fragment.
Add hybridapp.lifecycle breadcrumbs around start and destroy to confirm the trigger.
JS, optional defense-in-depth: remove the module-scope setAudioModeAsync in src/libs/Sound/index.native.ts and load expo-audio on first play. This is fine to do, but on its own it won't close APP-MD4 ❕
Heads-up: fix #1 lives in Mobile-Expensify, which external contributors can't change. @mountiny worth considering whether this should go Internal, or be split so the JS hardening stays External.
Side finding: Cannot initialize app loader ... RNHeadlessAppLoader (a ClassNotFoundException) shows up in nearly every event. That means expo-task-manager's headless path is broken in HybridApp release builds, so background GPS events get dropped when JS isn't running. It's separate from this crash but worth its own issue ❕
@MelvinBot please draft a formal proposal for this issue. The proposals above came in before Help Wanted so they can't be considered, and they all target only src/libs/Sound/index.native.ts, which I don't think fixes the root cause.
Findings from Sentry (APP-MD4, plus APP-MD7 which is the same crash back on 9.4.63):
The same group has more than the ExpoAudio error. All of these are thrown in ReactInstance::loadScript with [runtime not ready]:
Exception in HostFunction: null cannot be cast to non-null type com.facebook.react.fabric.FabricUIManager from NativeReanimatedModule (3 events)
Exception in HostFunction: Required value was null (3 events)
Every event is a cold start (SoLoader init 2 to 8s before the crash), one event per user, spread across devices and releases, so it looks like a race. Breadcrumbs: OldDot ExpensifyActivityBase resumed, yaplHideBootSplash x2, then Cannot initialize app loader. RNHeadlessAppLoader, then the crash. There are no openNewApp / ReactNativeFragment.onCreate / closeReactNativeFragment breadcrumbs, and one event (9.5.12-1) has no Activity at all.
Why I think it's not a Sound bug:
In ReactHostImpl.kt, reactInstance is assigned before loadJSBundle() and only nulled by the reload/destroy tasks. BridgelessReactContext.hasActiveReactInstance() and ReactHostImpl.uiManager both depend on it.
ExpoBridgeModule.installModules() busy-waits up to 1s for hasActiveReactInstance(). If the instance never appears, Expo modules aren't installed and requireNativeModule throws. Reanimated gets a null FabricUIManager for the same reason.
So the bundle is still evaluating after the ReactHost was destroyed or reloaded. ExpoAudio is just the first Expo module touched at module scope (App.ts -> @libs/Sound). Lazy-loading expo-audio alone would move the crash to the next native module ⚠️
What I'd like the proposal to cover:
Confirm the trigger. In HybridApp the only reactHost.destroy() is ReactNativeFragment.onDestroy when closeReactNativeFragment() sets shouldDestroyReactHostOnClose (via closeReactNativeApp, yaplCloseNewApp, DeepLinkManager). Also rule in or out a headless start (AirshipHeadlessEventService).
A native fix that makes ReactHost start() / destroy() safe against each other while the bundle is loading (for example, defer destroy until onReactContextInitialized, and have openNewApp wait for an in-flight destroy).
Optional JS hardening: drop the module-scope setAudioModeAsync in src/libs/Sound/index.native.ts and load expo-audio on first play.
A repro: Android HybridApp release build, cold start, then close or reopen NewDot before the bundle finishes loading.
cc @mountiny Looks like the main lives in Mobile-Expensify, so this may need to be Internal if Melvin doesn't have access like us reviewers, or split with the optional JS hardening part kept External.
Updated 2026-10-22: Corrected based on feedback from ikevin127.
Proposal
Please re-state the problem that we are trying to solve in this issue.
On a cold start, HybridApp Android aborts while the NewDot JS bundle is still evaluating. The usual error is Cannot find native module 'ExpoAudio'. The same Sentry group also has the Reanimated null cannot be cast to ... FabricUIManager error and Required value was null.
What is the root cause of that problem?
The native ReactHost is torn down (destroyed or reloaded) while its bundle is still evaluating on the JS thread. Every native module the bundle touches after that point fails. ExpoAudio is usually the first one.
A slow start alone can't cause this.reactInstance is set on the background executor (ReactHostImpl.kt:1061) before the bundle is queued to the JS thread (:1072). Bundle code can only see null after the reload task (:1406) or the destroy task (:1570) runs. A teardown is required.
App JS can't be the trigger. Every JS caller of closeReactNativeApp (HybridApp/index.ts:103-124 and the src/libs/HybridApp.ts listeners) needs the bundle to finish first. So the teardown comes from native code or the OS, either a HybridApp close or reopen of NewDot or a headless start.
Supporting evidence
Expo:ExpoBridgeModule.installModules (expo-modules-core 57) runs on the JS thread during module init. It polls hasActiveReactInstance() 10 × 100 ms, then continues silently. With a null instance, the JSI install returns early, globalThis.expo is never set, and requireNativeModule throws.
Reanimated:NativeProxy casts UIManagerHelper.getUIManager(...) to FabricUIManager with no wait, and that call returns null when reactInstance is null. That makes this error the clearest sign that the instance was already gone in the middle of bundle evaluation.
Why ExpoAudio comes first: Metro uses inlineRequires: true (metro.config.js:69), so modules are evaluated on first use, not in import order.
Headless entry points in App: the expo-task-manager GPS task (backgroundLocationTrackingTask/index.native.ts:34), the background-fetch task (backgroundTask/index.native.ts:8) and Airship push wake-ups (setup/index.ts:130-131). The RNHeadlessAppLoader breadcrumb in nearly every event suggests an expo-task-manager task tried to start JS during these cold starts. That is worth checking as the trigger alongside Airship.
What changes do you think we should make in order to solve the problem?
The real fix is native, in Mobile-Expensify. ikevin127 will open that PR, and it needs a second reviewer with Mobile-Expensify access.
Confirm the trigger. Add breadcrumbs around every ReactHost start, destroy and reload. Log which caller ran and whether an Activity exists. That will show whether the trigger is a HybridApp close or reopen, or a headless start (Airship or expo-task-manager).
Make start and destroy safe against each other. While the instance is still loading, queue a requested destroy and run it after the React context is initialized. Have the open-NewDot path wait for any in-flight destroy before it starts a new fragment or host.
Optional JS hardening (App): in src/libs/Sound/index.native.ts, move both the value import of expo-audio (line 9) and setAudioModeAsync (line 25) into a lazy loader that runs on first play. Removing only line 25 isn't enough, because importing expo-audio already calls requireNativeModule('ExpoAudio'). This can ship with the native fix or be left out. On its own it won't close APP-MD4.
Repro: build an Android HybridApp release, cold start, then close or reopen NewDot before the bundle finishes. Also cold start through a push notification and through a background GPS or fetch task.
What alternative solutions did you explore? (Optional)
Lazy-loading expo-audio alone: rejected. The Reanimated events in the same group show the next native module failing the same way.
A slow-device timeout in Expo's 1 s wait: ruled out by the code (see root cause point 1).
Self-destroy through handleHostException: ruled out. It calls handleInstanceException before destroy() (ReactHostImpl.kt:786-796). The default handler rethrows (DefaultReactHostDelegate.kt:44), so the app would crash on the original exception first. This crash also goes through ReactJsExceptionHandlerImpl.reportJsException, not handleHostException.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/104503#issuecomment-6330175521) to create a draft PR for the optional JS hardening. The native fix goes in a separate Mobile-Expensify PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
@MelvinBotplease update the proposal: drop root cause point 3 and fix step 3 (self-destroy via handleHostException). In HybridApp handleHostException never reaches destroy().
HybridApp builds its host with getDefaultReactHost(context, reactNativeHost, null), which falls back to exceptionHandler = { throw it } (DefaultReactHostDelegate.kt:44). In release, handleInstanceException(e) rethrows first and the app crashes on that original exception, which would be its own Sentry issue.
Our crash also goes through ReactJsExceptionHandlerImpl.reportJsException, not handleHostException.
@mountiny I and other C+ Reviewers have Mobile-Expensify access and can build HybridApp locally, so we can either request a C+ reviewer on Slack to open PR and I'll review it or I can open the native PR: breadcrumbs around ReactHost start/destroy, plus making start and destroy safe against each other while the bundle loads.
On the standalone Expensify/App side the only change is the optional hardening in src/libs/Sound/index.native.ts, which loads expo-audio on first play instead of at module scope. It can go in the same fix or be left out, since on its own it won't close APP-MD4.
I updated the proposal. You're right: handleHostException calls handleInstanceException before destroy(), and the default handler rethrows (DefaultReactHostDelegate.kt:44).
Changes:
I removed root cause point 3 and fix step 3, and listed self-destroy as a ruled-out alternative.
The native fix is now a Mobile-Expensify PR from you that needs a second reviewer.
The App change is the optional expo-audio lazy load, which can ship with the native fix or be left out.
Activity
melvin-bot commentedon Oct 20, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @ikevin127 (
External)[-][Sentry: APP-MD4] HybridApp Android: startup abort, "Cannot find native module 'ExpoAudio'"[/-][+][$175] [Sentry: APP-MD4] HybridApp Android: startup abort, "Cannot find native module 'ExpoAudio'"[/+]melvin-bot commentedon Oct 20, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022112637793222357815
juni2003 commentedon Oct 20, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~01867ae3203c9b7815?mp_source=share
Proposal
Please re-state the problem that we are trying to solve in this issue.
On Android HybridApp builds, the app crashes instantly on startup with a fatal
Cannot find native module 'ExpoAudio'error because the audio module is being invoked before the native runtime is fully ready.What is the root cause of that problem?
The root cause is a synchronous, top-level execution of a native module method during the initial JS bundle evaluation.
src/libs/Sound/index.native.ts,expo-audio(orexpo-av) is imported and configured (viasetAudioModeAsync) at the top-level scope of the file.Soundis imported early in the app lifecycle (e.g., throughsrc/libs/actions/App.ts), the JS engine evaluates this file synchronously during bundle load.ExpoAudionative module at the top level throws a fatalJavascriptExceptionbecauserequireNativeModulefires before the HybridApp has finished registering it, causing aSIGABRTstartup abort.What changes do you think we should make in order to solve the problem?
We need to defer the native audio initialization so it does not execute during the top-level bundle evaluation. We will lazily configure the audio mode only when a sound is actually about to be played, wrapped in a safety catch for the HybridApp context.
What alternative solutions did you explore? (Optional)
A purely native alternative would be patching the Android HybridApp host code to ensure the
ExpoAudiopackage is forced into the synchronous nativePackageListbefore the JS engine boots. This was rejected because React Native best practices dictate that JS should never assume native modules are synchronously available at bundle-load time. Lazy-loading the initialization in JS is much safer, prevents brittle startup crashes, and keeps the fix entirely within the cross-platform codebase.melvin-bot commentedon Oct 20, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
taieddie commentedon Oct 20, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~01af851c9a7f771775
Proposal
Please re-state the problem that we are trying to solve in this issue.
HybridApp on Android crashes during startup with a fatal SIGABRT because the JS bundle fails during evaluation with
Cannot find native module 'ExpoAudio'.What is the root cause of that problem?
In
src/libs/Sound/index.native.ts, line 9 statically importscreateAudioPlayerandsetAudioModeAsyncfromexpo-audio, while line 25 immediately callssetAudioModeAsyncduring top-level bundle evaluation. The entry point ofexpo-audioevaluatesAudioModule, which directly executesrequireNativeModule('ExpoAudio'). Because startup code likesrc/libs/actions/App.tsimportssrc/libs/Sound, this executes before the native module registry is available in HybridApp Android, causing an unhandled synchronous exception.What changes do you think we should make in order to solve the problem?
src/libs/Sound/index.native.ts, retain only the type-only importimport type {AudioSource} from 'expo-audio'at the module root, removing the value import ofcreateAudioPlayerandsetAudioModeAsync.setAudioModeAsyncat line 25.playSound: checkgetIsMuted(), then verifySOUND_ASSETS[soundFile].playSound, dynamically requireexpo-audiowrapped in a try/catch block. If the native module cannot be resolved or loaded, log a warning and return early without throwing.setAudioModeAsync({playsInSilentMode: false, shouldPlayInBackground: false, interruptionMode: 'mixWithOthers'}). If configuration fails, catch the error, reset any memoized state to avoid poisoning future attempts, and return before creating the audio player.What alternative solutions did you explore? (Optional)
Earlier proposals failed by only moving
setAudioModeAsyncinto a lazy helper while leaving the static value importimport {createAudioPlayer, setAudioModeAsync} from 'expo-audio'at module scope. This still crashes on bundle load becauseexpo-audioexecutesrequireNativeModule('ExpoAudio')immediately upon being imported. Deferring the runtime require entirely prevents the startup abort. It fails safely.melvin-bot commentedon Oct 20, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
haiquywork commentedon Oct 21, 2026
Proposal
What is the root cause of that problem?
src/libs/Sound/index.native.tsstatically importsexpo-audioand also callssetAudioModeAsyncat module top level.expo-audiorunsrequireNativeModule('ExpoAudio')as soon as it is evaluated.@libs/Soundis imported by startup code such assrc/libs/actions/App.tsandSession/index.ts, so that native lookup happens during the first bundle evaluation. If the native module isn't registered yet in a HybridApp launch, the error is thrown with no handler and the app aborts. Sound is not needed to start the app, but it can currently crash it.App/src/libs/Sound/index.native.ts
Lines 9 to 25 in 16d77a9
App/src/libs/actions/App.ts
Line 10 in 16d77a9
What changes do you think we should make in order to solve the problem?
src/libs/Sound/index.native.ts, remove the runtime import ofexpo-audio(keepimport typeonly) and remove the top-levelsetAudioModeAsynccall.expo-audiowith a dynamicimport()inside try/catch, caches the result, and callssetAudioModeAsynconce. The cached promise replacesaudioModeConfigured.playSound, callawait loadAudio(). If it returns null, return silently, and log it withLog.warn. Otherwise usecreateAudioPlayerfrom the loaded module.getIsMutedcheck and thewithMinimalExecutionTimewrapper stay as they are.tests/unitthat mocksexpo-audioto throw on load. It checks that importing@libs/Sounddoes not throw and thatplaySoundresolves without error. A second test checks that audio mode is configured once, before the first player is created.What alternative solutions did you explore? (Optional)
require. This works, but it still does native lookups at startup and it is harder to type than a lazy loader.jloa-dev commentedon Oct 21, 2026
Contributor details
Your Expensify account email: [email protected]
Upwork Profile Link: https://www.upwork.com/freelancers/~0163955a2ba221e513
Proposal
Please re-state the problem that we are trying to solve in this issue.
HybridApp on Android crashes during application startup with a fatal SIGABRT / Tombstone crash:
com.facebook.react.common.JavascriptException: [runtime not ready]: Error: Cannot find native module 'ExpoAudio'thrown while the JS bundle is being evaluated during
ReactInstance::loadScript.What is the root cause of that problem?
src/libs/Sound/index.native.ts, line 9 statically importscreateAudioPlayer, setAudioModeAsyncfromexpo-audio:src/libs/actions/App.tsandsrc/libs/actions/Session/index.ts, import{clearSoundAssetsCache} from '@libs/Sound'. Therefore,Sound/index.native.tsis evaluated immediately when Metro loads and runs the initial JS bundle.requireNativeModule('ExpoAudio')is invoked synchronously before the native JSI/modules runtime is fully ready and registered.ReactInstance::loadScript), it cannot be caught by any React ErrorBoundary, triggeringfacebook::react::JsErrorHandler::handleErrorWithCppPipelineand terminating the process viastd::terminate -> abort() (SIGABRT).What alternative solutions did you explore? (Optional)
Calling
setAudioModeAsyncinside anApp.tslifecycle hook was explored, but static imports ofexpo-audiostill trigger module-level initialization code inexpo-audio/src/index.ts. Lazy dynamic import (import('expo-audio')) on demand when sound is actually requested completely isolates startup from audio native module readiness.What changes do you think we should make in order to solve the problem?
In
src/libs/Sound/index.native.ts:expo-audio(createAudioPlayer,setAudioModeAsync).const audioModeConfigured = setAudioModeAsync(...).What automated tests can you add to prevent this from re-occurring?
Add a test in
tests/unit/SoundTest.tsverifying that:@libs/Sounddoes not trigger any native calls or throw even when native audio modules are unavailable.playSoundsafely handles failure without crashing when native modules reject.melvin-bot commentedon Oct 21, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
mountiny commentedon Oct 21, 2026
@ikevin127 can you please review the proposals?
ikevin127 commentedon Oct 21, 2026
@mountiny Reviewing today. Do I ask Melvin for a formal proposal once I verify the issue ? Asking since the above proposals which came in before
Help Wantedlabel was added on the issue, guidelines say:mountiny commentedon Oct 21, 2026
Yeah ask Melvin please
ikevin127 commentedon Oct 22, 2026
(Spoiler) 🔍 Investigation Findings
The issue's suspected cause is incomplete. ExpoAudio isn't missing in some builds. The JS bundle is being evaluated after the React instance it belongs to has gone away, and ExpoAudio is just the first native module the bundle touches at module scope (App.ts → @libs/Sound → setAudioModeAsync).
What Sentry shows (APP-MD4, 33 events, 32 users)
ExpoAudio. Within the same group, all thrown inReactInstance::loadScriptwith [runtime not ready]:Cannot find native module 'ExpoAudio'Exception in HostFunction: null cannot be cast to non-null type com.facebook.react.fabric.FabricUIManager, thrown from NativeReanimatedModule (Reanimated, nothing to do with Sound)Exception in HostFunction: Required value was nullFETCH_PREVIEW_ENABLEDflag mismatch), which are noiseExpensifyActivityBaseis created and resumed,yaplHideBootSplashfires twice, then the Expo logCannot initialize app loader.RNHeadlessAppLoaderappears and the crash follows.openNewApp,ReactNativeFragment.onCreateorcloseReactNativeFragmentin any event I sampled. One event (9.5.12-1) had no Activity at all, so the process started in the background.Root cause (from reading RN 0.86 / Expo 57 / HybridApp source)
ReactHostImpl.kt,reactInstanceis assigned beforeloadJSBundle(). It's only set back to null in the reload task (L1406) and the destroy task (L1570).BridgelessReactContext.hasActiveReactInstance()andReactHostImpl.uiManagerboth depend onreactInstance != null.ExpoBridgeModule.installModules()waits up to 1s forhasActiveReactInstance()(its own comment admits this race). If the instance never shows up,globalThis.expois never installed andrequireNativeModulethrows.nullFabricUIManager for the same reason.So both crash flavours mean the ReactHost was destroyed or reloaded while the bundle was still evaluating. In HybridApp the only
reactHost.destroy()caller isReactNativeFragment.onDestroy, whenshouldDestroyReactHostOnCloseis set bycloseReactNativeFragment(). That's reached fromcloseReactNativeApp,yaplCloseNewAppandDeepLinkManager.java:283.A close or reopen of NewDot during the slow cold-start bundle load fits the timing. The missing NewDot breadcrumbs plus the background-start event also leave a headless start as a possibility (Airship's AirshipHeadlessEventService is the only other code that starts the ReactHost). A repro should confirm which trigger it is.
Fix direction
destroy()andstart()safe against each other.reactHost.destroy()while the instance is still loading. Defer it untilonReactContextInitialized.openNewApp, wait for any in-flight destroy task to finish before adding a new fragment.hybridapp.lifecyclebreadcrumbs around start and destroy to confirm the trigger.setAudioModeAsyncinsrc/libs/Sound/index.native.tsand load expo-audio on first play. This is fine to do, but on its own it won't close APP-MD4 ❕Heads-up: fix
#1lives inMobile-Expensify, which external contributors can't change. @mountiny worth considering whether this should goInternal, or be split so the JS hardening staysExternal.Side finding:
Cannot initialize app loader ... RNHeadlessAppLoader (a ClassNotFoundException)shows up in nearly every event. That means expo-task-manager's headless path is broken in HybridApp release builds, so background GPS events get dropped when JS isn't running. It's separate from this crash but worth its own issue ❕@MelvinBot please draft a formal proposal for this issue. The proposals above came in before
Help Wantedso they can't be considered, and they all target onlysrc/libs/Sound/index.native.ts, which I don't think fixes the root cause.Findings from Sentry (APP-MD4, plus APP-MD7 which is the same crash back on 9.4.63):
ExpoAudioerror. All of these are thrown inReactInstance::loadScriptwith[runtime not ready]:Cannot find native module 'ExpoAudio'(~25 events)Exception in HostFunction: null cannot be cast to non-null type com.facebook.react.fabric.FabricUIManagerfromNativeReanimatedModule(3 events)Exception in HostFunction: Required value was null(3 events)SoLoaderinit 2 to 8s before the crash), one event per user, spread across devices and releases, so it looks like a race. Breadcrumbs: OldDotExpensifyActivityBaseresumed,yaplHideBootSplashx2, thenCannot initialize app loader. RNHeadlessAppLoader, then the crash. There are noopenNewApp/ReactNativeFragment.onCreate/closeReactNativeFragmentbreadcrumbs, and one event (9.5.12-1) has no Activity at all.Why I think it's not a Sound bug:
ReactHostImpl.kt,reactInstanceis assigned beforeloadJSBundle()and only nulled by the reload/destroy tasks.BridgelessReactContext.hasActiveReactInstance()andReactHostImpl.uiManagerboth depend on it.ExpoBridgeModule.installModules()busy-waits up to 1s forhasActiveReactInstance(). If the instance never appears, Expo modules aren't installed andrequireNativeModulethrows. Reanimated gets a nullFabricUIManagerfor the same reason.ExpoAudiois just the first Expo module touched at module scope (App.ts->@libs/Sound). Lazy-loadingexpo-audioalone would move the crash to the next native moduleWhat I'd like the proposal to cover:
HybridAppthe onlyreactHost.destroy()isReactNativeFragment.onDestroywhencloseReactNativeFragment()setsshouldDestroyReactHostOnClose(viacloseReactNativeApp,yaplCloseNewApp,DeepLinkManager). Also rule in or out a headless start (AirshipHeadlessEventService).start()/destroy()safe against each other while the bundle is loading (for example, defer destroy untilonReactContextInitialized, and haveopenNewAppwait for an in-flight destroy).setAudioModeAsyncinsrc/libs/Sound/index.native.tsand loadexpo-audioon first play.HybridApprelease build, cold start, then close or reopen NewDot before the bundle finishes loading.cc @mountiny Looks like the main lives in
Mobile-Expensify, so this may need to beInternalif Melvin doesn't have access like us reviewers, or split with the optional JS hardening part keptExternal.MelvinBot commentedon Oct 22, 2026
Updated 2026-10-22: Corrected based on feedback from
ikevin127.Proposal
Please re-state the problem that we are trying to solve in this issue.
On a cold start, HybridApp Android aborts while the NewDot JS bundle is still evaluating. The usual error is
Cannot find native module 'ExpoAudio'. The same Sentry group also has the Reanimatednull cannot be cast to ... FabricUIManagererror andRequired value was null.What is the root cause of that problem?
The native
ReactHostis torn down (destroyed or reloaded) while its bundle is still evaluating on the JS thread. Every native module the bundle touches after that point fails.ExpoAudiois usually the first one.reactInstanceis set on the background executor (ReactHostImpl.kt:1061) before the bundle is queued to the JS thread (:1072). Bundle code can only seenullafter the reload task (:1406) or the destroy task (:1570) runs. A teardown is required.closeReactNativeApp(HybridApp/index.ts:103-124and thesrc/libs/HybridApp.tslisteners) needs the bundle to finish first. So the teardown comes from native code or the OS, either a HybridApp close or reopen of NewDot or a headless start.Supporting evidence
ExpoBridgeModule.installModules(expo-modules-core 57) runs on the JS thread during module init. It pollshasActiveReactInstance()10 × 100 ms, then continues silently. With a null instance, the JSI install returns early,globalThis.expois never set, andrequireNativeModulethrows.NativeProxycastsUIManagerHelper.getUIManager(...)toFabricUIManagerwith no wait, and that call returns null whenreactInstanceis null. That makes this error the clearest sign that the instance was already gone in the middle of bundle evaluation.ExpoAudiocomes first: Metro usesinlineRequires: true(metro.config.js:69), so modules are evaluated on first use, not in import order.backgroundLocationTrackingTask/index.native.ts:34), the background-fetch task (backgroundTask/index.native.ts:8) and Airship push wake-ups (setup/index.ts:130-131). TheRNHeadlessAppLoaderbreadcrumb in nearly every event suggests an expo-task-manager task tried to start JS during these cold starts. That is worth checking as the trigger alongside Airship.What changes do you think we should make in order to solve the problem?
The real fix is native, in
Mobile-Expensify.ikevin127will open that PR, and it needs a second reviewer withMobile-Expensifyaccess.ReactHoststart, destroy and reload. Log which caller ran and whether an Activity exists. That will show whether the trigger is a HybridApp close or reopen, or a headless start (Airship or expo-task-manager).Optional JS hardening (App): in
src/libs/Sound/index.native.ts, move both the value import ofexpo-audio(line 9) andsetAudioModeAsync(line 25) into a lazy loader that runs on first play. Removing only line 25 isn't enough, because importingexpo-audioalready callsrequireNativeModule('ExpoAudio'). This can ship with the native fix or be left out. On its own it won't close APP-MD4.Repro: build an Android HybridApp release, cold start, then close or reopen NewDot before the bundle finishes. Also cold start through a push notification and through a background GPS or fetch task.
What alternative solutions did you explore? (Optional)
expo-audioalone: rejected. The Reanimated events in the same group show the next native module failing the same way.handleHostException: ruled out. It callshandleInstanceExceptionbeforedestroy()(ReactHostImpl.kt:786-796). The default handler rethrows (DefaultReactHostDelegate.kt:44), so the app would crash on the original exception first. This crash also goes throughReactJsExceptionHandlerImpl.reportJsException, nothandleHostException.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/104503#issuecomment-6330175521)to create a draft PR for the optional JS hardening. The native fix goes in a separateMobile-ExpensifyPR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
ikevin127 commentedon Oct 22, 2026
@MelvinBot please update the proposal: drop root cause point 3 and fix step 3 (self-destroy via
handleHostException). In HybridApphandleHostExceptionnever reachesdestroy().HybridApp builds its host with
getDefaultReactHost(context, reactNativeHost, null), which falls back toexceptionHandler = { throw it }(DefaultReactHostDelegate.kt:44). In release,handleInstanceException(e)rethrows first and the app crashes on that original exception, which would be its own Sentry issue.Our crash also goes through
ReactJsExceptionHandlerImpl.reportJsException, nothandleHostException.@mountiny I and other C+ Reviewers have Mobile-Expensify access and can build HybridApp locally, so we can either request a C+ reviewer on Slack to open PR and I'll review it or I can open the native PR: breadcrumbs around
ReactHoststart/destroy, plus making start and destroy safe against each other while the bundle loads.On the standalone Expensify/App side the only change is the optional hardening in
src/libs/Sound/index.native.ts, which loadsexpo-audioon first play instead of at module scope. It can go in the same fix or be left out, since on its own it won't close APP-MD4.MelvinBot commentedon Oct 22, 2026
I updated the proposal. You're right:
handleHostExceptioncallshandleInstanceExceptionbeforedestroy(), and the default handler rethrows (DefaultReactHostDelegate.kt:44).Changes:
Mobile-ExpensifyPR from you that needs a second reviewer.expo-audiolazy load, which can ship with the native fix or be left out.view run
mountiny commentedon Oct 22, 2026
@ikevin127 yeah feel fre to go ahead with the PR and we can find another reviewer, if you prefer
ikevin127 commentedon Oct 23, 2026
@mountiny 🟢 HybridApp PR https://github.com/Expensify/Mobile-Expensify/pull/14199 is ready for review!
daledah commentedon Oct 23, 2026
I assigned my self since i was the C+ on this PR