Skip to content

[$175] [Sentry: APP-M66] Android: ReactModalHostView updateSystemAppearance "dialog must exist" #103778

Open

Description

@mountiny

Sentry

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

Impact (snapshot at filing, 2026-10-12)

  • Users (total since first seen): 19
  • Users (last 30d, releases 9.5.5 to 9.5.7 only): 19
  • Events (30d window): 19
  • First seen: 2026-09-21
  • Last seen: ongoing
  • Platform: Android hybrid app
  • App version(s): 9.5.5
  • Affected route(s): Report screen
  • Mechanism: UncaughtExceptionHandler

Stack trace (top frames, first-party only)

ReactModalHostView.showOrUpdate$lambda$5 (ReactModalHostView.kt:348)
ReactModalHostView.updateSystemAppearance (ReactModalHostView.kt:440)

Suspected cause

A posted updateSystemAppearance runs after the modal dialog is already dismissed, so React Native throws 'dialog must exist when we call updateProperties'.

Reproduction

Unknown. See the events linked from the Sentry issue.

Related

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

Activity

  1. self-assigned this
    on Oct 12, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 12, 2026
  3. melvin-bot commented on Oct 12, 2026

    @melvin-bot

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

  4. changed the title [-][Sentry: APP-M66] Android: ReactModalHostView updateSystemAppearance "dialog must exist"[/-] [+][$175] [Sentry: APP-M66] Android: ReactModalHostView updateSystemAppearance "dialog must exist"[/+] on Oct 12, 2026
  5. melvin-bot commented on Oct 12, 2026

    @melvin-bot
  6. haiquywork commented on Oct 12, 2026

    @haiquywork

    Proposal

    What is the root cause of that problem?

    Our own React Native patch react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch (added in #69004) adds window.decorView.post { updateSystemAppearance() } inside ReactModalHostView.showOrUpdate, right after newDialog.show().

    updateSystemAppearance()
    + // Activity and dialog insets can settle after attach; re-sync so nav icon style matches.
    + window.decorView.post { updateSystemAppearance() }
    window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)

    The post runs on a later UI-thread tick. If the modal is dismissed in between (common with quick-closing Report screen modals), dismiss() has already set the dialog field to null. updateSystemAppearance then does a checkNotNull on dialog and throws, which matches the trace (showOrUpdate$lambda$5 -> updateSystemAppearance). The unpatched code only called it synchronously right after show(), so it could not hit this.

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

    • Update patch 019 so the deferred callback is safe once the modal is gone, and regenerate the patch file with patch-package.
    • Heart of the change:
    window.decorView.post {
      val d = dialog
      if (d != null && d.isShowing) updateSystemAppearance()
    }
    • Optionally also have the post callback skip if the decorView is no longer attached (window.decorView.isAttachedToWindow).
    • Update the 019 entry in patches/react-native/details.md to mention the guard.
    • Testing: there's no JS unit test for this native patch. Verify manually on Android by opening a modal on a report and dismissing it immediately (rapid open/close), and check there's no crash.

    What alternative solutions did you explore? (Optional)

    • Wrap the call in try/catch: hides the error rather than checking the state, so worse.
    • Make updateSystemAppearance itself return early when dialog is null: also fine, but it changes more upstream code than needed, and the posted lambda is the only caller that runs late.
    • Remove the post: it would bring back the nav bar icon style bug that patch 019 was fixing.
  7. MelvinBot commented on Oct 12, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    Our react-native patch schedules a delayed updateSystemAppearance() call that can run after the modal has closed.

    Fix: Navigation bar translucency on Android added this line to ReactModalHostView.showOrUpdate() in react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch:18:

    window.decorView.post { updateSystemAppearance() }

    The crash happens in this order:

    1. A modal opens, and showOrUpdate() posts the runnable to the dialog's decor view.
    2. The modal closes before the next frame, for example on a quick open and close or on unmount. dismiss() sets dialog = null.
    3. The posted runnable runs. updateSystemAppearance() starts with checkNotNull(dialog) { "dialog must exist when we call updateProperties" }, which throws IllegalStateException and crashes the app.
    Evidence
    • The Sentry frame showOrUpdate$lambda$5 (ReactModalHostView.kt:348) matches line 348 of the patched file, which holds the posted call. The crash is in the lambda, not in the direct updateSystemAppearance() call one line above it.
    • In the patched file, dismiss() sets dialog = null at line 210, and updateSystemAppearance() runs checkNotNull(dialog) at line 440. Nothing cancels the posted runnable.
    • The PR merged on 2026-09-08. Sentry first saw the crash on 2026-09-21 in 9.5.5, the first release that shipped it.
    • The other call the patch adds, if (dialog.isShowing) updateSystemAppearance() in updateProperties(), is safe because it runs synchronously while dialog is non-null.

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

    In patch 019, update the posted runnable so it calls updateSystemAppearance() only if dialog still exists and is showing when the runnable runs. Otherwise, it does nothing. Make this a small change to the patch itself, and keep the patch header in patches/react-native/details.md accurate.

    This keeps the navigation bar fix from the PR above and removes the crash.

    What alternative solutions did you explore? (Optional)

    • Make updateSystemAppearance() return early when dialog is null, instead of using checkNotNull. This also works, but it changes upstream behavior for every caller and could hide real bugs.
    • Keep a reference to the Runnable and call removeCallbacks in dismiss(). This is more precise but adds more code to the patch for the same result.

    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/103778) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run

  8. mountiny commented on Oct 12, 2026

    @mountiny
    ContributorAuthor

    (Vit's agent) @cretadn22, can you review the proposal from MelvinBot on this issue? Please tell us which of these fits:

    1. This is ready to open for contributors as it stands.
    2. We should ask for expert contributor help, because it needs deeper native, navigation or FlashList knowledge.
    3. The proposal needs changes first.
  9. jloa-dev commented on Oct 13, 2026

    @jloa-dev

    Proposal

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

    On Android, a Sentry crash (APP-M66) occurs with java.lang.IllegalStateException: dialog must exist when we call updateProperties inside ReactModalHostView.updateSystemAppearance.

    What is the root cause of that problem?

    In our React Native patch patches/react-native/react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch#L18, we schedule a deferred UI tick to update system bar appearances:

    window.decorView.post { updateSystemAppearance() }

    When a modal is rapidly closed or unmounted immediately after opening, dismiss() runs before the posted runnable executes on the looper queue, setting dialog = null. When the posted runnable subsequently runs, updateSystemAppearance() executes:

    checkNotNull(dialog) { "dialog must exist when we call updateProperties" }

    which throws an IllegalStateException and crashes the application because dialog is null.

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

    1. In patches/react-native/react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch, safeguard the deferred runnable by verifying that dialog still exists and is currently showing:
    window.decorView.post {
        val currentDialog = dialog
        if (currentDialog != null && currentDialog.isShowing) {
            updateSystemAppearance()
        }
    }
    1. Re-apply and verify the patch with npx patch-package react-native.
    2. Update patches/react-native/details.md with the null-safety rationale for patch 019.

    What alternative solutions did you explore? (Optional)

    • Wrapping updateSystemAppearance() in a try-catch block: rejected because silently swallowing exceptions is defensive anti-pattern compared to explicit null/lifecycle checks.
    • Cancelling the runnable via removeCallbacks: adds unnecessary runnable reference tracking when a simple guard check achieves the exact same protection cleanly.
  10. cretadn22 commented on Oct 14, 2026

    @cretadn22
    Contributor

    @mountiny We should ask for expert contributor help, because it needs deeper native

  11. tnclaudecode commented on Oct 17, 2026

    @tnclaudecode

    Proposal

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

    On Android (HybridApp, Report screen) the app crashes with IllegalStateException: dialog must exist when we call updateProperties, thrown from ReactModalHostView.updateSystemAppearance when it's called from the runnable posted in showOrUpdate (showOrUpdate$lambda$5).

    What is the root cause of that problem?

    Our patch react-native+0.86.0+019+fix-modal-transparent-navigation-bar.patch adds a deferred re-sync after newDialog.show():
    window.decorView.post { updateSystemAppearance() }.
    That runnable sits on the dialog window's decor view, not on the ReactModalHostView, so nothing cancels it when the modal goes away. It then reads the dialog field of the host view, which dismiss() sets to null. updateSystemAppearance() starts with checkNotNull(dialog), so it throws.

    Upstream RN 0.86 dismiss() (ReactModalHostView.kt, lines 195-216) can null the field in two ways, and the second explains why this shows up on HybridApp:

    1. Quick open → close. The modal is dismissed before the next frame (JS unmounts it, onDetachedFromWindow → onDropInstance, or a prop such as animationType sets createNewDialog and showOrUpdate dismisses and recreates it). The runnable is already queued on the main looper and runs afterwards.
    2. The host activity is finishing. dismiss() deliberately skips nonNullDialog.dismiss() when the dialog's activity isFinishing, but still sets dialog = null. The dialog window stays attached until the activity's windows are torn down, so the posted runnable is guaranteed to run against a null field. HybridApp finishes the React Native activity while a modal can still be open (returning to the classic app), which fits Sentry's Android HybridApp + Report screen signature.

    There's also a quieter variant of case 1. If the dialog was replaced rather than removed, the stale runnable from the old dialog runs updateSystemAppearance() on the new dialog before that dialog's own insets have settled. It doesn't crash, but it's the wrong dialog.

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

    Update patch 019 so the deferred re-sync is tied to the dialog it was scheduled for and is cancelled with it, instead of only null-checking:

    1. In showOrUpdate, keep a reference to the runnable and to the dialog it was posted for. When it runs, only call updateSystemAppearance() if the host view's dialog is still that same dialog instance and it is showing; otherwise do nothing.
    2. In dismiss(), remove the pending runnable from that dialog's decor view (removeCallbacks) before clearing dialog. Do this outside the isShowing branch, so it also covers the finishing-activity path where nonNullDialog.dismiss() is skipped.
    3. Leave updateSystemAppearance()'s checkNotNull as it is, so any other caller with a genuinely missing dialog still fails loudly instead of being silently ignored.
    4. Update the 019 entry in patches/react-native/details.md to describe the deferred sync and how it's cancelled.

    Testing on Android: open and close a modal within one frame (rapid tap on a report action that opens a modal); open a modal on the Report screen and close the React Native activity from HybridApp; change a prop that recreates the dialog while it is open. None should crash, and the transparent navigation bar fix from #69004 / #87810 should still apply when a modal opens normally.

    What alternative solutions did you explore? (Optional)

    • Only add dialog != null && dialog.isShowing in the runnable (the existing proposals). It stops this crash, but leaves the runnable alive after dismissal, and lets a stale runnable from a replaced dialog act on the new one.
    • Replace the post with a one-shot pre-draw listener on the dialog's decor view. It's cancelled automatically when the decor view detaches, but in the finishing-activity case the decor view doesn't detach, so the null case still needs the identity check above.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorOverdue

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions