Skip to content

fix(android): commit the portal fragment only once its container view is attached - #81

Open
dmvk wants to merge 2 commits into
ionic-team:mainfrom
dmvk:fix-android-portal-fragment-container-race
Open

dmvk wants to merge 2 commits into
ionic-team:mainfrom
dmvk:fix-android-portal-fragment-container-race

Conversation

@dmvk

@dmvk dmvk commented Sep 30, 2026 •

Copy link
Copy Markdown

Problem

On Android, PortalView can crash the host app with an uncaught

java.lang.IllegalArgumentException: No view found for id 0x4c4 (unknown) for fragment PortalFragment{1ee6db1} (52940c13-… id=0x4c4 tag=1220)
    at androidx.fragment.app.FragmentStateManager.createView(FragmentStateManager.java:567)
    at androidx.fragment.app.FragmentStateManager.moveToExpectedState(FragmentStateManager.java:286)
    at androidx.fragment.app.FragmentManager.executeOpsTogether(FragmentManager.java:2214)
    at androidx.fragment.app.FragmentManager.removeRedundantOperationsAndExecute(FragmentManager.java:2109)
    at androidx.fragment.app.FragmentManager.execPendingActions(FragmentManager.java:2052)
    at androidx.fragment.app.FragmentManager$4.run(FragmentManager.java:703)
    at android.os.Handler.handleCallback(Handler.java:995)
    …

Seen in production (Android 16, @ionic/portals-react-native 0.9.0, React Native 0.81.5 with the New Architecture, react-native-screens 4.16.0). Both occurrences we have follow the same shape: the user leaves a screen that hosts a PortalView, comes back to it within a second or two, and the app dies about 150 ms after the tap. On the re-open the portal config is already cached, so the PortalView mounts a few milliseconds after the screen is pushed. main has the same PortalView.kt as 0.9.0 and 0.9.1, so this applies to the current release.

Root cause

PortalViewManager.createFragment commits the PortalFragment transaction asynchronously:

activity.supportFragmentManager
    .beginTransaction()
    .replace(viewId, portalFragment, "$viewId")
    .commit()

FragmentManager runs that on the next main-loop iteration and only then resolves the container by id (FragmentContainer.onFindViewById → Activity.findViewById). If the container is not part of the activity's window at that moment, FragmentStateManager.createView throws the exception above. There are three ways to get there from a React Native app:

  1. The container is not attached yet. The create command runs as soon as the JS side mounts the view, but the surrounding screen may still be animating in. react-native-screens attaches a screen's fragment view via its own transaction, and if that lands after ours executes, findViewById fails. This is the case we observe.
  2. The view was dropped before the transaction ran. React mounts and unmounts the PortalView in one batch. onDropViewInstance tries to cancel through fragment.parentFragmentManager, but that property throws until the add has executed, so the try/catch swallowed it and the orphaned add ran anyway. (Queuing a remove behind the add would not help either: removeRedundantOperationsAndExecute executes non-reorderable, non-pop records one at a time.)
  3. The view is still mounted in React but detached natively. react-native-screens detaches the view of a popped or covered screen while React keeps the subtree mounted; a PortalView mounting in that window hits the same exception.

Fix

Commit the transaction with commitNowAllowingStateLoss() from a Runnable posted on the container view, and re-check state when it runs:

  • View.post() queues the runnable until the view is attached (it goes through the view's HandlerActionQueue, which re-posts on the main handler at attach time), and always dispatches through the main handler. So the commit never nests inside another FragmentManager transaction that may be executing, which is the hazard a plain commitNow at command time would have with react-native-screens' own commitNow transactions.
  • When the runnable runs it bails if the view has been dropped meanwhile (identity check against the manager's state map), and re-posts itself if the view is not attached (covers a detach between attach and dispatch).
  • viewState.fragment is only set after a successful add, so onDropViewInstance can use parentFragmentManager without the try/catch.

Behaviour change to be aware of: the fragment is now added synchronously inside the posted runnable rather than on FragmentManager's own schedule, and commitAllowingStateLoss replaces commit on the remove path (the previous code caught and logged the state-loss exception, so nothing that used to succeed changes).

Alternative considered

setReorderingAllowed(true) on both transactions makes FragmentManager batch a pending add with a later remove and skip view creation entirely, which fixes case 2 on its own. It does nothing for case 1 (the container exists but is not in the window), which is the one we see in production, so it was not enough.

Testing

Regression tests (new)

The library module had no unit test setup, so this PR adds JUnit + Robolectric to android/build.gradle and a dedicated test-android CI job that runs ./gradlew :ionic_portals-react-native:testDebugUnitTest on every push and prints the JUnit summary. It is a separate job rather than a step in build-android on purpose: that job is replayed from the turbo cache whenever yarn.lock is unchanged (the android entry in turbo.json inputs does not hash the directory's contents), so a step gated on the cache would be skipped exactly when android/ changed. You may want to fix that input glob separately; I left it alone here. Robolectric is used because the behaviour under test is FragmentManager + view attachment semantics, which need a real activity/window but not an emulator.

To make the logic testable without booting a Capacitor bridge and WebView, the "commit once attached and still ours" part is extracted into runWhenAttached(view, isCurrent, action); createFragment calls it with the same checks as before. RunWhenAttachedTest drives the real androidx FragmentManager with plain Fragments:

Test What it proves
asyncCommitThrowsWhenTheContainerWasRemovedBeforeItRan Control. The pre-fix pattern (async commit(), container removed, looper runs) throws No view found for id under Robolectric, so the harness reproduces the production crash.
commitsOnceTheLooperRunsWhenTheContainerIsAttached Normal path still adds the fragment.
skipsTheCommitWhenTheViewWasDroppedBeforeItRan Case 2: dropped view → no commit, no fragment, no exception.
waitsForTheContainerToAttachBeforeCommitting Case 1: nothing happens while the container is not in the window; the fragment is added once it attaches (exercises the pre-attach View.post queue).
doesNotThrowWhenTheContainerIsDetachedWhileStillCurrent Case 3: detached while still mounted → no exception; commits when the container comes back.

CI run with the tests (test-android: 5 tests, 0 failures): https://github.com/dmvk/ionic-portals-react-native/actions/runs/36722825635

Other verification

  • Compiled by this fork's build-android CI job (example app, Kotlin 2.1.20, compileSdk 36), first run before the tests were added: https://github.com/dmvk/ionic-portals-react-native/actions/runs/36718978352
  • Static analysis against the androidx.fragment sources for removeRedundantOperationsAndExecute / executeOpsTogether (batching semantics) and android.view.View#post / HandlerActionQueue#executeActions (pre-attach posts are re-posted through the handler, not run synchronously).
  • Production evidence: two crash reports with breadcrumbs showing the re-open sequence above; a variant of this fix is being shipped to that app as a pnpm patch and I will report back here once it has been in the field.
  • Not done: I have not run the example app on a device for this change (no Android SDK on the machine I authored it on). If a maintainer can run yarn example android, that would be a welcome check.

Reproducing the crash without the fix

Case 1 is the one with production evidence: host the PortalView in a @react-navigation/native-stack screen that renders it immediately on mount, then pop and re-push that screen quickly (the faster the PortalView mounts after the push, the more likely the crash). Case 2 needs two React commits back to back so the mount and the unmount land in one native batch, for example a PortalView whose parent unmounts it from a mount effect; separate taps on a toggle button are usually too far apart to hit it.

🤖 Generated with Claude Code

… is attached

PortalViewManager.createFragment committed the PortalFragment transaction
asynchronously. FragmentManager resolves the container view by id when the
transaction executes on the next main-loop iteration and throws

  IllegalArgumentException: No view found for id 0x… for fragment PortalFragment

when the container is not part of the activity's window at that moment:

- React dropped the PortalView before the transaction ran (mount and unmount
  in the same batch). onDropViewInstance could not cancel the pending add,
  because Fragment.parentFragmentManager throws until the add has executed
  and the catch swallowed it.
- The view exists but is not attached yet, e.g. a react-native-screens screen
  that is still animating in when the PortalView mounts (seen in production
  when a portal screen is re-opened with a cached portal config), or it was
  detached while React still has the subtree mounted.

Commit the transaction with commitNowAllowingStateLoss from a Runnable posted
on the container view instead. View.post defers the runnable until the view is
attached and always dispatches it through the main handler, so the commit never
nests inside another FragmentManager transaction. When it runs, it re-checks
that the view was not dropped and is attached. viewState.fragment is only set
after a successful add, so onDropViewInstance can use parentFragmentManager
without the try/catch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 30, 2026

Copy link
Copy Markdown

@dmvk is attempting to deploy a commit to the Ionic Team on Vercel.

A member of the Team first needs to authorize it.

Extract the "commit once the container is attached and still ours" logic
into runWhenAttached so it can be exercised without PortalFragment (which
would boot a Capacitor bridge and WebView), and cover it with Robolectric
against the real androidx FragmentManager:

- control test reproducing the production failure: an async commit whose
  container was removed before it ran throws "No view found for id"
- commits once the looper runs when the container is attached
- skips the commit when the view was dropped before it ran
- waits for the container to attach before committing
- does not throw when the container is detached while still current, and
  commits once it comes back

The library module had no unit test setup; this adds junit + robolectric as
test dependencies and a dedicated test-android CI job that runs
testDebugUnitTest on every push. It is a separate job on purpose: the
build-android job is replayed from the turbo cache whenever yarn.lock is
unchanged, so a step gated on that cache would be skipped exactly when
android/ changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dmvk
dmvk force-pushed the fix-android-portal-fragment-container-race branch from b640785 to 735a488 Compare September 30, 2026 13:36

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant