Skip to content

[$175] [Sentry: APP-MD4] HybridApp Android: startup abort, "Cannot find native module 'ExpoAudio'" #104503

Open

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-MD4

Impact (snapshot at filing)

  • Users (total since first seen): 31
  • Events: 32
  • Users (last 14d, all versions): 15
  • Users (last 14d, production 9.5.11 to 9.5.13): 4
  • First seen: 2026-09-22 (about 4 weeks ago)
  • Last seen: 2026-10-20 (ongoing)
  • Platform: Android HybridApp
  • 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.

Related

  • Sibling Sentry IDs: none confirmed
  • Prior GH issues: none found
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @daledah

Activity

  1. self-assigned this
    on Oct 20, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 20, 2026
  3. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @ikevin127 (External)

  4. changed the title [-][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'"[/+] on Oct 20, 2026
  5. melvin-bot commented on Oct 20, 2026

    @melvin-bot
  6. juni2003 commented on Oct 20, 2026

    @juni2003

    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.

    1. 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.
    2. 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.
    3. 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 ...
    
    let isAudioConfigured = false;
    
    function configureAudio() {
        if (isAudioConfigured) {
            return;
        }
        try {
            Audio.setAudioModeAsync({
                playsInSilentModeIOS: true,
                // ... existing configuration ...
            });
            isAudioConfigured = true;
        } catch (error) {
            Log.hmmm('Failed to initialize ExpoAudio in HybridApp', {error});
        }
    }
    
    export default function playSound(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.

  7. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  8. taieddie commented on Oct 20, 2026

    @taieddie

    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 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?

    1. 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.
    2. Remove the top-level evaluation of setAudioModeAsync at line 25.
    3. Keep the existing guard order in playSound: check getIsMuted(), then verify SOUND_ASSETS[soundFile].
    4. 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.
    5. 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.
    6. 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.

  9. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  10. haiquywork commented on Oct 21, 2026

    @haiquywork

    Proposal

    What is the root cause of that problem?

    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.

    import {createAudioPlayer, setAudioModeAsync} from 'expo-audio';
    import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound';
    // Sound assets are statically imported for bundling.
    const SOUND_ASSETS = {
    [SOUNDS.DONE]: doneSound,
    [SOUNDS.SUCCESS]: successSound,
    [SOUNDS.ATTENTION]: attentionSound,
    [SOUNDS.RECEIVE]: receiveSound,
    } satisfies Record<ValueOf<typeof SOUNDS>, Extract<AudioSource, number | string>>;
    // Configure audio mode for in-app notification sounds:
    // - playsInSilentMode: false — respect the device's silent/mute switch (iOS)
    // - shouldPlayInBackground: false — push notification sounds are played by the system
    // - interruptionMode: 'mixWithOthers' — don't interrupt other audio (e.g. music)
    const audioModeConfigured = setAudioModeAsync({playsInSilentMode: false, shouldPlayInBackground: false, interruptionMode: 'mixWithOthers'});

    import {clearSoundAssetsCache} from '@libs/Sound';

    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.
    let audioPromise: Promise<typeof import('expo-audio') | null> | undefined;
    function loadAudio() {
        audioPromise ??= import('expo-audio')
            .then(async (m) => { await m.setAudioModeAsync({...}); return m; })
            .catch(() => null);
        return audioPromise;
    }
    • 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.
  11. jloa-dev commented on Oct 21, 2026

    @jloa-dev

    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?

    1. In src/libs/Sound/index.native.ts, line 9 statically imports createAudioPlayer, setAudioModeAsync from expo-audio:
      import {createAudioPlayer, setAudioModeAsync} from 'expo-audio';
      And line 25 immediately invokes native module initialization at the module top level:
      const audioModeConfigured = setAudioModeAsync({playsInSilentMode: false, shouldPlayInBackground: false, interruptionMode: 'mixWithOthers'});
    2. 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.
    3. 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.
    4. 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:

    1. Remove the static import of expo-audio (createAudioPlayer, setAudioModeAsync).
    2. Remove top-level invocation const audioModeConfigured = setAudioModeAsync(...).
    3. Lazily initialize and cache the audio mode and player on demand with graceful fallback:
    --- a/src/libs/Sound/index.native.ts
    +++ b/src/libs/Sound/index.native.ts
    @@ -6,9 +6,8 @@ import type {AudioSource} from 'expo-audio';
     import type {ValueOf} from 'type-fest';
     
    -import {createAudioPlayer, setAudioModeAsync} from 'expo-audio';
    -
     import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound';
    +import Log from '@libs/Log';
     
     // Sound assets are statically imported for bundling.
     const SOUND_ASSETS = {
    @@ -21,11 +20,20 @@ const SOUND_ASSETS = {
     // - playsInSilentMode: false — respect the device's silent/mute switch (iOS)
     // - shouldPlayInBackground: false — push notification sounds are played by the system
     // - interruptionMode: 'mixWithOthers' — don't interrupt other audio (e.g. music)
    -const audioModeConfigured = setAudioModeAsync({playsInSilentMode: false, shouldPlayInBackground: false, interruptionMode: 'mixWithOthers'});
    +let audioModePromise: Promise<void> | null = null;
    +const ensureAudioModeConfigured = () => {
    +    if (!audioModePromise) {
    +        audioModePromise = (async () => {
    +            try {
    +                const {setAudioModeAsync} = await import('expo-audio');
    +                await setAudioModeAsync({playsInSilentMode: false, shouldPlayInBackground: false, interruptionMode: 'mixWithOthers'});
    +            } catch (error) {
    +                Log.warn('[Sound] Failed to configure audio mode:', error);
    +            }
    +        })();
    +    }
    +    return audioModePromise;
    +};
     
     const playSound = async (soundFile: ValueOf<typeof SOUNDS>) => {
         if (getIsMuted()) {
             return;
         }
     
         const source = SOUND_ASSETS[soundFile];
         if (!source) {
             return;
         }
     
    -    // Wait for audio mode to be configured before playing to ensure the session
    -    // uses the .ambient category (which won't interrupt music or other audio)
    -    await audioModeConfigured;
    -
    -    const player = createAudioPlayer(source);
    +    try {
    +        await ensureAudioModeConfigured();
    +        const {createAudioPlayer} = await import('expo-audio');
    +        const player = createAudioPlayer(source);
    +
    +        const subscription = player.addListener('playbackStatusUpdate', (status) => {
    +            if (!status.didJustFinish) {
    +                return;
    +            }
    +
    +            subscription.remove();
    +            player.remove();
    +        });
    +
    +        player.play();
    +    } catch (error) {
    +        Log.warn('[Sound] Failed to play sound:', error);
    +    }
     };

    What automated tests can you add to prevent this from re-occurring?

    Add a test in tests/unit/SoundTest.ts verifying that:

    1. Importing @libs/Sound does not trigger any native calls or throw even when native audio modules are unavailable.
    2. Invoking playSound safely handles failure without crashing when native modules reject.
  12. melvin-bot commented on Oct 21, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  13. mountiny commented on Oct 21, 2026

    @mountiny
    ContributorAuthor

    @ikevin127 can you please review the proposals?

  14. ikevin127 commented on Oct 21, 2026

    @ikevin127
    Contributor

    @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)

  15. mountiny commented on Oct 21, 2026

    @mountiny
    ContributorAuthor

    Yeah ask Melvin please

  16. ikevin127 commented on Oct 22, 2026

    @ikevin127
    Contributor
    (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).

    ⚠️ 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.

    Root cause (from reading RN 0.86 / Expo 57 / HybridApp source)

    • 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 null FabricUIManager 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

    1. 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.
    2. 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]:
      • Cannot find native module 'ExpoAudio' (~25 events)
      • 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:

    1. 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).
    2. 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).
    3. Optional JS hardening: drop the module-scope setAudioModeAsync in src/libs/Sound/index.native.ts and load expo-audio on first play.
    4. 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.

  17. MelvinBot commented on Oct 22, 2026

    @MelvinBot
    Contributor

    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.

    1. 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.
    2. 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.

    1. 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).
    2. 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.

  18. ikevin127 commented on Oct 22, 2026

    @ikevin127
    Contributor

    @MelvinBot please 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.

  19. MelvinBot commented on Oct 22, 2026

    @MelvinBot
    Contributor

    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.

    view run

  20. mountiny commented on Oct 22, 2026

    @mountiny
    ContributorAuthor

    @ikevin127 yeah feel fre to go ahead with the PR and we can find another reviewer, if you prefer

  21. ikevin127 commented on Oct 23, 2026

    @ikevin127
    Contributor
  22. daledah commented on Oct 23, 2026

    @daledah
    Contributor

    I assigned my self since i was the C+ on this PR

  23. self-assigned this
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in review

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions