Skip to content

[Due for payment 2026-07-23] [$500] [Sentry: APP-B2Q] HybridApp iOS C++ JSError across JSI bridge #93845

Closed

Description

@mountiny

Sentry

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

Impact (snapshot at filing)

  • Users (total since first seen): 100
  • Events: 200
  • Users (last 14d, 9.4.x releases): 4
  • First seen: 2026-04-23
  • Last seen: ongoing
  • Platform: iOS HybridApp
  • App version(s): 9.4.5–9.4.9
  • Affected route(s): unknown (JSI bridge)
  • Mechanism: cpp_exception

Stack trace (top frames, first-party only)

C++ Exception: N8facebook3jsi7JSErrorE
(facebook::jsi::JSError thrown across native boundary)

Suspected cause

Unhandled JavaScript exception propagates as a C++ JSError through the JSI bridge — needs symbolicated JS stack from Sentry events.

Reproduction

Unknown — see Sentry events linked from the Sentry issue.

Related

  • Prior GH issues: none
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. self-assigned this
    on Jun 17, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    Help WantedApply this label when an issue is open to proposals by contributors
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jun 17, 2026
  3. melvin-bot commented on Jun 17, 2026

    @melvin-bot

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

  4. melvin-bot commented on Jun 17, 2026

    @melvin-bot
  5. MelvinBot commented on Jun 17, 2026

    @MelvinBot
    Contributor

    ⚠️ Preliminary analysis (low confidence on root cause) — this crash signature is a generic bucket, and the specific JS error can't be pinned without reading a Sentry event. Treat the next steps as the actionable part.

    Issue Analysis

    What this signature actually is: C++ Exception: N8facebook3jsi7JSErrorE (facebook::jsi::JSError) is not a distinct bug — it's the catch-all that appears whenever any unhandled JavaScript exception propagates across the JSI native boundary on HybridApp iOS. The C++ frames are the native bridge unwinding; they are never actionable on their own. This is why the issue shows affected route: unknown and bundles 100 users / 200 events — many unrelated JS errors are grouped under the single C++ frame.

    Why the current data isn't enough: The "top frames, first-party only" block only contains the native side. The real signal — the JS exception class (e.g. RangeError/TypeError), its message, and the symbolicated JS stack — lives inside the Sentry events for APP-B2Q, not in this issue body. The issue's own "Suspected cause" already notes this. I attempted to pull the events via the Sentry integration but it isn't authenticated in this automated run, so I couldn't retrieve the JS stack.

    Precedent (what these usually turn out to be): The last time an identical facebook::jsi::JSError crash was filed on HybridApp iOS (Expensify/App#51853), the underlying JS error was RangeError: Maximum call stack size exceeded from infinite recursion in getRootParentReport. That specific path is now guarded with cycle detection at src/libs/ReportUtils.ts:1346-1352, so this is a different unhandled exception (or a new recursion source) — not a regression of that fix.

    Fix location is unknown until the JS stack is read. Because this is HybridApp iOS, the throwing code could be in App JS or in the Mobile-Expensify native/bridge layer. Per repo policy I won't propose a code fix for a root cause I can't locate.


    Next Steps for Contributor+ team:

    1. Open a recent event under Sentry APP-B2Q and read the full exception. Sentry stores the real message (Unhandled JS Exception: <ErrorClass>: <message> ... stack: <jsFn>@1:... ) and the symbolicated JS frames on the event even though the grouping title only shows the C++ frame.
    2. That message gives the exact error class + the throwing JS function. If multiple distinct JS errors appear across events, this bucket should be split rather than fixed as one.
    3. Once the throwing function is known, decide whether the fix belongs in Expensify/App (JS) or Mobile-Expensify (native) and re-run analysis.

    To refine: Reply @MelvinBot the JS exception from Sentry is: <paste message + stack> and I'll pinpoint the root cause and propose a fix.

  6. Abdulloh0109 commented on Jun 17, 2026

    @Abdulloh0109
    Contributor

    What is the root cause of that problem?

    APP-B2Q isn't a single bug — it's the catch-all bucket for unhandled JS exceptions that escape into native on iOS HybridApp. C++ Exception: N8facebook3jsi7JSErrorE with mechanism cpp_exception is precisely how Hermes surfaces a JS throw that nothing on the JS side caught: the exception is rethrown across the JSI HostFunction boundary into the OldDot native host and aborts the process. We've already fixed two instances of this exact signature one-off — #57786 (Unhandled JS Exception: TypeError … 'signupQualifier' of undefined, with a stack through getOnboardingInitialPath → startOnboardingFlow → Onyx's processNext/_processUpdate) and #51853. APP-B2Q is the remainder that never got a JS stack attached, which is why it reads as "route: unknown (JSI bridge)".

    Two things make this a recurring, opaque group:

    1. The only error boundary is render-scoped

    BaseErrorBoundary wraps react-error-boundary, which by design only catches errors thrown during React render/lifecycle. The throws behind these crashes fire in non-render contexts — Onyx connection/update callbacks, navigation init, event handlers, async tasks. That's exactly the getOnboardingInitialPath path from #57786: OnboardingFlow.ts, where currentOnboardingValues?.signupQualifier is now read through ?. after that crash. The boundary never sees these.

    2. There is no native catch-all

    On web, ErrorBoundary/index.tsx registers window.addEventListener('unhandledrejection', …), so a stray rejection gets logged instead of being fatal. The native boundary ErrorBoundary/index.native.tsx registers nothing equivalent, and there is no ErrorUtils.setGlobalHandler anywhere in src/. So an uncaught throw on native has no JS-side net — it goes straight to Hermes' default fatal path and across the bridge as jsi::JSError.

    Why the group has no JS frames

    In HybridApp the JS-side Sentry.init runs with autoInitializeNativeSdk: false — see setupSentry.ts — so the native Sentry SDK (AppDelegate) owns crash capture. When the throw aborts as a C++ exception, it's the native SDK that records it, carrying only the N8facebook3jsi7JSErrorE frame and none of the JS stack the @sentry/react-native JS error handler would have attached. That's why every sibling row in the #91302 tracker names a specific culprit and this one can't.

    What changes do you think we should make in order to solve the problem?

    I'd split this into "make it actionable" and "stop it being fatal".

    1. Attach JS stacks so the group can be split (this is what the issue asks for)

    Add a JS-side capture for uncaught/fatal errors on native so the JS stack is recorded before the native C++ frame is all that survives — register a global handler in the native ErrorBoundary layer (index.native.tsx) that mirrors web's unhandledrejection listener and calls Sentry.captureException(error) with the original JS error. In parallel, confirm Hermes sourcemaps for the 9.4.5–9.4.9 iOS HybridApp release bundles are uploaded to Sentry so those JS frames symbolicate. Layer: telemetry/config + native ErrorBoundary entry point. Once frames land, APP-B2Q fans out into per-culprit trackers like its siblings.

    2. Add the missing native safety net (generalize the one-off guards)

    Install an ErrorUtils.setGlobalHandler / unhandled-rejection analog on native that logs the JS error (with stack) as a handled Sentry event and, for non-fatal throws, keeps the app alive — the native counterpart of the web unhandledrejection net that already exists. This stops us from patching each new x of undefined site (#57786, #51853) one production crash at a time.

    I'll be honest about scope: the specific throwing line(s) behind APP-B2Q can only be pinned from the Sentry events themselves — that's the literal meaning of the issue's "needs symbolicated JS stack". Step 1 is what unblocks that; #57786 shows the per-site fix is then trivial (a ?. or a default).

    What alternative solutions did you explore? (Optional)

  7. lorretheboy commented on Jun 17, 2026

    @lorretheboy
    Contributor

    Proposal

    What is the root cause of that problem?

    At HybridApp startup, HybridAppHandler requests the settings string that OldDot hands to NewDot and parses it. The parse is an unguarded JSON.parse:

    function parseHybridAppSettings(hybridAppSettings: string | null): HybridAppSettings | null {
    if (!hybridAppSettings) {
    return null;
    }
    return JSON.parse(hybridAppSettings) as HybridAppSettings;
    }

    This runs inside the promise chain returned by getHybridAppSettings:

    function getHybridAppSettings(): Promise<HybridAppSettings | null> {
    return HybridAppModule.getHybridAppSettings().then((hybridAppSettings) => {
    return parseHybridAppSettings(hybridAppSettings);
    });
    }

    and the consumer attaches a .then but no .catch:

    getHybridAppSettings().then((hybridAppSettings: HybridAppSettings | null) => {
    if (!hybridAppSettings) {
    // Native method can send non-null value only once per NewDot lifecycle. It prevents issues with multiple initializations during reloads on debug builds.
    Log.info('[HybridApp] `getHybridAppSettings` called more than once during single NewDot lifecycle. Skipping initialization.');
    addBootsplashBreadcrumb('HybridAppHandler: null settings, skipping');
    return;
    }
    addBootsplashBreadcrumb('HybridAppHandler: Settings received', {loggedOutFromOldDot: String(!!hybridAppSettings.hybridApp.loggedOutFromOldDot)});
    // Resolve splash state ASAP — this is the earliest moment we know
    // whether the native splash is on screen or not
    if (hybridAppSettings.hybridApp.loggedOutFromOldDot) {
    setSplashScreenState(CONST.BOOT_SPLASH_STATE.HIDDEN);
    } else {
    setSplashScreenState(CONST.BOOT_SPLASH_STATE.VISIBLE);
    }
    if (hybridAppSettings.hybridApp.loggedOutFromOldDot) {
    startSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT, {
    name: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT,
    op: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT,
    startTime: hybridAppSettings.hybridApp.transitionStartTimestamp,
    });
    } else if (hybridAppSettings.hybridApp.pressedTryNewExpensify) {
    startSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION, {
    name: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION,
    op: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION,
    startTime: hybridAppSettings.hybridApp.transitionStartTimestamp,
    });
    }
    finalizeTransitionFromOldDot(hybridAppSettings);
    });

    Causal chain: OldDot passes a settings string that is non-null but malformed (truncated/corrupted across the native handoff) → JSON.parse throws synchronously inside .then → the promise rejects → there is no .catch anywhere in the chain → the unhandled rejection surfaces on Hermes/JSI during startup as a native facebook::jsi::JSError (cpp_exception). The same .then block then assumes a well-formed object and dereferences hybridAppSettings.hybridApp.loggedOutFromOldDot, so even a structurally-valid-but-incomplete payload throws the same way.

    What changes do you think we should make in order to solve the problem?

    Make the parse fail safe at the single convergence point. Wrap the JSON.parse in parseHybridAppSettings in a try/catch, log the failure, and return null so the existing if (!hybridAppSettings) skip path in the handler takes over instead of crashing:

    function parseHybridAppSettings(hybridAppSettings: string | null): HybridAppSettings | null {
    if (!hybridAppSettings) {
    return null;
    }
    return JSON.parse(hybridAppSettings) as HybridAppSettings;
    }

    In words: if parsing throws, Log the error and return null. Because every caller already routes through getHybridAppSettings, this one change protects all entry points.

    As a defensive backstop, also attach a .catch to the chain in HybridAppHandler so any rejection (or a thrown property access in the .then) is logged rather than left unhandled:

    getHybridAppSettings().then((hybridAppSettings: HybridAppSettings | null) => {
    if (!hybridAppSettings) {
    // Native method can send non-null value only once per NewDot lifecycle. It prevents issues with multiple initializations during reloads on debug builds.
    Log.info('[HybridApp] `getHybridAppSettings` called more than once during single NewDot lifecycle. Skipping initialization.');
    addBootsplashBreadcrumb('HybridAppHandler: null settings, skipping');
    return;
    }
    addBootsplashBreadcrumb('HybridAppHandler: Settings received', {loggedOutFromOldDot: String(!!hybridAppSettings.hybridApp.loggedOutFromOldDot)});
    // Resolve splash state ASAP — this is the earliest moment we know
    // whether the native splash is on screen or not
    if (hybridAppSettings.hybridApp.loggedOutFromOldDot) {
    setSplashScreenState(CONST.BOOT_SPLASH_STATE.HIDDEN);
    } else {
    setSplashScreenState(CONST.BOOT_SPLASH_STATE.VISIBLE);
    }
    if (hybridAppSettings.hybridApp.loggedOutFromOldDot) {
    startSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT, {
    name: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT,
    op: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT,
    startTime: hybridAppSettings.hybridApp.transitionStartTimestamp,
    });
    } else if (hybridAppSettings.hybridApp.pressedTryNewExpensify) {
    startSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION, {
    name: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION,
    op: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION,
    startTime: hybridAppSettings.hybridApp.transitionStartTimestamp,
    });
    }
    finalizeTransitionFromOldDot(hybridAppSettings);
    });

    What alternative solutions did you explore? (Optional)

    Validate the shape of the parsed object (presence of hybridApp) before use and treat a missing/invalid shape the same as null. This addresses the secondary dereference crash but is more code than the try/catch and the .catch already cover.

  8. github-actions commented on Jun 17, 2026

    @github-actions
    Contributor

    ⚠️ @lorretheboy Thanks for your proposal. Please update it to follow the proposal template, as proposals are only reviewed if they follow that format (note the mandatory sections).

  9. David-2002065 commented on Jun 17, 2026

    @David-2002065
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On iOS HybridApp, a JS exception escapes into native as C++ Exception: N8facebook3jsi7JSErrorE (mechanism: cpp_exception) with no symbolicated JS stack, so APP-B2Q is an opaque catch-all that can't be triaged or split into per-culprit issues.

    What is the root cause of that problem?

    The app already has a net that attaches a JS stack to ordinary throws: the JS-thread handler wired by Sentry.wrap(App) (

    App/src/App.tsx

    Lines 141 to 143 in 82b779a

    </SplashScreenStateContextProvider>
    </StrictModeWrapper>
    );
    ) plus @sentry/react-native's default RN error-handler integration. That is exactly why the other HybridApp crashes of this family (#57786, #51853) landed with readable JS frames and were fixable one-off.

    APP-B2Q is the remainder that never reaches that net: the throws that fire inside the OldDot→NewDot startup bridge continuations, which run as the direct follow-up of a native HybridApp call rather than as ordinary app code, and which have no error isolation of their own:

    • the getHybridAppSettings() consumer and finalizeTransitionFromOldDot, which unconditionally dereference hybridAppSettings.hybridApp.* —
      const finalizeTransitionFromOldDot = (hybridAppSettings: HybridAppSettings) => {
      const loggedOutFromOldDot = !!hybridAppSettings.hybridApp.loggedOutFromOldDot;
      setupNewDotAfterTransitionFromOldDot(hybridAppSettings, tryNewDot).then(() => {
      if (loggedOutFromOldDot) {
      endSpan(CONST.TELEMETRY.SPAN_APP_STARTUP);
      endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.ROOT);
      endSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT);
      } else {
      setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN);
      }
      });
      };
      useEffect(() => {
      if (!CONFIG.IS_HYBRID_APP || isLoadingTryNewDot) {
      return;
      }
      addBootsplashBreadcrumb('HybridAppHandler: Requesting settings');
      getHybridAppSettings().then((hybridAppSettings: HybridAppSettings | null) => {
      if (!hybridAppSettings) {
      // Native method can send non-null value only once per NewDot lifecycle. It prevents issues with multiple initializations during reloads on debug builds.
      Log.info('[HybridApp] `getHybridAppSettings` called more than once during single NewDot lifecycle. Skipping initialization.');
      addBootsplashBreadcrumb('HybridAppHandler: null settings, skipping');
      return;
      }
      addBootsplashBreadcrumb('HybridAppHandler: Settings received', {loggedOutFromOldDot: String(!!hybridAppSettings.hybridApp.loggedOutFromOldDot)});
      // Resolve splash state ASAP — this is the earliest moment we know
      // whether the native splash is on screen or not
      if (hybridAppSettings.hybridApp.loggedOutFromOldDot) {
      setSplashScreenState(CONST.BOOT_SPLASH_STATE.HIDDEN);
      } else {
      setSplashScreenState(CONST.BOOT_SPLASH_STATE.VISIBLE);
      }
      if (hybridAppSettings.hybridApp.loggedOutFromOldDot) {
      startSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT, {
      name: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT,
      op: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION_LOGGED_OUT,
      startTime: hybridAppSettings.hybridApp.transitionStartTimestamp,
      });
      } else if (hybridAppSettings.hybridApp.pressedTryNewExpensify) {
      startSpan(CONST.TELEMETRY.SPAN_OD_ND_TRANSITION, {
      name: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION,
      op: CONST.TELEMETRY.SPAN_OD_ND_TRANSITION,
      startTime: hybridAppSettings.hybridApp.transitionStartTimestamp,
      });
      }
      finalizeTransitionFromOldDot(hybridAppSettings);
      });
    • setupNewDotAfterTransitionFromOldDot, which destructures and reaches into hybridApp.delegateAccessData?.* / runs Onyx.multiSet against the handed-off payload —
      function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettings, tryNewDot?: TryNewDot) {
      const {hybridApp, ...newDotOnyxValues} = hybridAppSettings;
      const clearOnyxIfSigningIn = () => {
      if (!hybridApp.useNewDotSignInPage) {
      return Promise.resolve();
      }
      // Don't clear Onyx when using imported state to preserve imported data (reports, transactions, etc.)
      if (isUsingImportedState) {
      Log.info('[HybridApp] Skipping Onyx clear because using imported state');
      return Promise.resolve();
      }
      Log.info(`[HybridApp] Clearing onyx after transition from OldDot. useNewDotSignInPage set to ${hybridApp.useNewDotSignInPage}`);
      return redirectToSignIn();
      };
      const resetDidUserLoginDuringSessionIfNeeded = () => {
      if (newDotOnyxValues.nvp_tryNewDot === undefined || tryNewDot?.classicRedirect?.dismissed !== true) {
      return Promise.resolve();
      }
      Log.info("[HybridApp] OpenApp hasn't been called yet. Calling `resetDidUserLogInDuringSession`");
      resetDidUserLogInDuringSession();
      };
      return clearOnyxIfSigningIn()
      .then(() => {
      // This section controls copilot changes
      const currentUserEmail = getCurrentUserEmail();
      // If ND and OD account are the same - do nothing
      if (hybridApp?.delegateAccessData?.oldDotCurrentUserEmail === currentUserEmail) {
      Log.info('[HybridApp] User did not switch account on OldDot side');
      return;
      }
      const stashedData = hybridApp?.delegateAccessData?.isDelegateAccess
      ? {
      [ONYXKEYS.STASHED_CREDENTIALS]: credentials,
      [ONYXKEYS.STASHED_SESSION]: session,
      }
      : {
      [ONYXKEYS.STASHED_CREDENTIALS]: {},
      [ONYXKEYS.STASHED_SESSION]: {},
      };
      // Don't clear Onyx when using imported state to preserve imported data (reports, transactions, etc.)
      // Just update the session and credentials without clearing
      if (isUsingImportedState) {
      Log.info('[HybridApp] Skipping Onyx clear because using imported state. Updating session only.');
      return Onyx.multiSet({
      ...stashedData,
      [ONYXKEYS.SESSION]: {
      email: hybridApp?.delegateAccessData?.oldDotCurrentUserEmail,
      authToken: hybridApp?.delegateAccessData?.oldDotCurrentAuthToken,
      encryptedAuthToken: decodeURIComponent(hybridApp?.delegateAccessData?.oldDotCurrentEncryptedAuthToken ?? ''),
      accountID: hybridApp?.delegateAccessData?.oldDotCurrentAccountID,
      },
      [ONYXKEYS.CREDENTIALS]: {
      autoGeneratedLogin: credentials?.autoGeneratedLogin ?? hybridApp.delegateAccessData?.oldDotAutoGeneratedLogin,
      autoGeneratedPassword: credentials?.autoGeneratedPassword ?? hybridApp.delegateAccessData?.oldDotAutoGeneratedPassword,
      },
      })
      .then(() => Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: hybridApp?.delegateAccessData?.oldDotCurrentUserEmail}))
      .then(() => {
      confirmReadyToOpenApp();
      return openApp();
      });
    • the module-level HybridApp Onyx subscribers that fire on the data OldDot pushes during the handoff —
      Onyx.connectWithoutView({
      key: ONYXKEYS.NVP_TRY_NEW_DOT,
      callback: (tryNewDot) => {
      const isInitialTryNewDotUpdate = !hasReceivedTryNewDotUpdate;
      hasReceivedTryNewDotUpdate = true;
      currentTryNewDot = tryNewDot;
      updateTryNewDotLoadingState(true, isInitialTryNewDotUpdate);
      },
      });
      Onyx.connectWithoutView({
      key: ONYXKEYS.IS_LOADING_APP,
      callback: (loadingApp) => {
      isLoadingApp = loadingApp ?? true;
      updateTryNewDotLoadingState();
      },
      });
      Onyx.connectWithoutView({
      key: ONYXKEYS.SESSION,
      callback: (session) => {
      const nextSessionAccountID = getSessionAccountID(session);
      if (nextSessionAccountID === currentSessionAccountID) {
      return;
      }
      const isInitialSessionLoad = currentSessionAccountID === undefined;
      currentSessionAccountID = nextSessionAccountID;
      if (isInitialSessionLoad) {
      return;
      }
      currentTryNewDot = undefined;
      hasReceivedTryNewDotUpdate = false;
      isLoadingTryNewDot = nextSessionAccountID !== undefined || isLoadingApp !== false;
      },
      });

    None of these blocks is wrapped in try/catch, and the app registers no JS-thread net on native equivalent to the web unhandledrejection listener (web:

    function ErrorBoundary({errorMessage, children}: Omit<BaseErrorBoundaryProps, 'logError'>) {
    // Log unhandled promise rejections to the server
    useEffect(() => {
    window.addEventListener('unhandledrejection', onUnhandledRejection);
    return () => window.removeEventListener('unhandledrejection', onUnhandledRejection);
    }, []);
    vs native, which registers nothing:
    function ErrorBoundary({errorMessage, children}: Omit<BaseErrorBoundaryProps, 'logError'>) {
    return (
    <BaseErrorBoundary
    errorMessage={errorMessage}
    logError={logError}
    >
    {children}
    </BaseErrorBoundary>
    );
    }
    ). So when one of these continuations throws, the error is re-raised across the JSI boundary and recorded by the native iOS Sentry SDK (the JS SDK runs with autoInitializeNativeSdk: false on HybridApp —
    enableLogs: true,
    beforeSendLog: processBeforeSendLogs,
    // In HybridApp, native SDK is initialized early in Application.onCreate (Android) and
    ) as a bare facebook::jsi::JSError, with no JS stack and "route: unknown".

    What changes do you think we should make in order to solve the problem?

    Isolate each HybridApp bridge continuation so a throw is captured with its JS stack (which is what makes APP-B2Q splittable — the issue's literal ask) and the app degrades gracefully instead of aborting. Add one shared guard and apply it at the bridge entry points:

    // Runs a HybridApp bridge continuation in isolation: a throw is reported to Sentry WITH its JS
    // stack (so it no longer surfaces as a stack-less native `facebook::jsi::JSError`) and is swallowed
    // so the OldDot->NewDot transition degrades instead of crashing.
    function runHybridAppBridgeStep<T>(label: string, step: () => T): T | undefined {
        try {
            return step();
        } catch (error) {
            Sentry.captureException(error, {tags: {hybridAppBridgeStep: label}});
            Log.alert(`[HybridApp] Uncaught error in bridge step "${label}"`, {error: String(error)});
            return undefined;
        }
    }

    Wrap the getHybridAppSettings().then(...) body and finalizeTransitionFromOldDot in HybridAppHandler with it, and do the same for the synchronous bodies of the HybridApp Onyx subscribers in src/libs/actions/HybridApp/index.ts. Because Sentry.captureException runs on the JS thread with the original Error, the event now carries the symbolicated JS frames the native SDK was dropping — once frames land, APP-B2Q fans out into per-culprit trackers like its siblings, and each is then a trivial ?./default fix.

    Note: the exact throwing field/line can only be confirmed from a symbolicated event — that's the meaning of the issue's "needs symbolicated JS stack" — and the capture above is precisely what restores it. To also symbolicate any other JS frames, confirm the Hermes sourcemaps for the 9.4.5–9.4.9 iOS HybridApp bundles are uploaded to Sentry.

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    Cover that a malformed/incomplete handoff payload does not crash the transition: when hybridAppSettings.hybridApp (or a delegateAccessData field) is missing, finalizeTransitionFromOldDot / setupNewDotAfterTransitionFromOldDot should call Sentry.captureException once and resolve (transition skipped) rather than throw. Assert the guard swallows the error and the app continues.

    What alternative solutions did you explore? (Optional)

    • Installing a JS global net (ErrorUtils.setGlobalHandler and/or a native unhandledrejection analog). It's worth adding for the broader surface, but it does not reliably execute in a native-invoked bridge continuation, so it wouldn't guarantee a JS stack for this specific class — capturing at the bridge step does.
    • Guarding only the JSON.parse in parseHybridAppSettings and returning null. That hardens the parse step, but the same payload is dereferenced again downstream in setupNewDotAfterTransitionFromOldDot (hybridApp.delegateAccessData?.*, Onyx.multiSet), so wrapping the continuation covers both the parse and the downstream access at one altitude.
  10. 78 remaining items

  11. ahmedGaber93 commented on Jul 20, 2026

    @ahmedGaber93
    Contributor
  12. changed the title [-][Due for payment 2026-07-23] [$250] [Sentry: APP-B2Q] HybridApp iOS C++ JSError across JSI bridge[/-] [+][Due for payment 2026-07-23] [$500] [Sentry: APP-B2Q] HybridApp iOS C++ JSError across JSI bridge[/+] on Jul 21, 2026
  13. melvin-bot commented on Jul 21, 2026

    @melvin-bot

    Upwork job price has been updated to $500

  14. mountiny commented on Jul 21, 2026

    @mountiny
    ContributorAuthor

    Ok sounds good!

  15. melvin-bot commented on Jul 23, 2026

    @melvin-bot

    @mallenexpensify Whoops! This issue is 2 days overdue. Let's get this updated quick!

  16. ahmedGaber93 commented on Jul 24, 2026

    @ahmedGaber93
    Contributor

    @ahmedGaber93 what's status here? Please comment and tag me if/when payment is due, thx

    @mallenexpensify The job is complete and ready for payment. Thanks!

  17. mallenexpensify commented on Jul 27, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @ahmedGaber93 due $500 via NewDot

  18. flaviadefaria commented on Sep 2, 2026

    @flaviadefaria
    Contributor

    Approved: @ahmedGaber93 due $500 via NewDot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions