Skip to content

fix(Android, Stack v5): preventNativeDismiss survives activity restart - #4594

Draft
kkafar wants to merge 3 commits into
@kkafar/refactor-back-button-workingsfrom
@kkafar/prevent-native-dismiss-system-back
Draft

fix(Android, Stack v5): preventNativeDismiss survives activity restart#4594
kkafar wants to merge 3 commits into
@kkafar/refactor-back-button-workingsfrom
@kkafar/prevent-native-dismiss-system-back

Conversation

@kkafar

@kkafar kkafar commented Sep 3, 2026

Copy link
Copy Markdown
Member

Description

Stacked on #4578 (header chevron scoped to its own stack).

preventNativeDismiss on Android (Stack v5) silently stops intercepting the system back press after the activity goes through one stop/start cycle (app switch, lock screen, home button). From then on the screen pops as if the flag were off, until its fragment is recreated.

Root cause (verified against androidx.activity 1.8.1 / androidx.fragment 1.8.9): OnBackPressedDispatcher hands a back press to the last-added enabled callback. Every FragmentManager registers its pop callback with the lifecycle-owner overload, which removes it on the owner's ON_STOP and re-appends it at the deque tail on ON_START. Our per-fragment PreventNativeDismissCallback was registered with the plain overload, so its slot never moved. One background cycle was enough for every FragmentManager callback to leapfrog every prevent callback.

Fix: each StackContainer owns a single veto-only OnBackPressedCallback, registered with the same lifecycle owner its FragmentManager uses (the hosting fragment when nested, FragmentActivity's fragment host at the root). LifecycleRegistry re-dispatches ON_START in registration order, so after every restart the deque is rebuilt as [fm, veto] per level. Deeper fragments start after their parents, so a deeper stack with something to pop still beats a shallower veto. FragmentManager keeps executing pops, so predictive back is unaffected. System back now resolves through the same recursive wantsToPreventStackNativeDismiss() query as the header chevron from #4578.

Note: the root owner is obtained through FragmentManager.getHost(), which is @RestrictTo(LIBRARY) (lint-suppressed). There is no public accessor. The activity's own lifecycle cannot be used: it starts after every fragment and would put the root veto behind all nested FragmentManager callbacks.

Closes https://github.com/software-mansion/react-native-screens-labs/issues/1775

Changes

  • StackContainer: new System back veto region. Lifecycle-owned veto callback (registered on attach, removed on detach), enabled predicate recomputed eagerly, re-dispatch to the next callback when the state went stale, emitNativeDismissPrevented shared with the chevron path, ancestor invalidation walk.
  • FragmentManagerHelper: findFragmentManagerWithOwnerForView returns the manager together with its lifecycle owner and dispatcher. findFragmentManagerForView is now non-null (it never returned null).
  • StackScreen: preventNativeDismiss changes invalidate the owning containers instead of notifying a single observer.
  • Removed: PreventNativeDismissCallback, PreventNativeDismissChangeObserver, top-fragment bookkeeping in StackScreenFragment, updateTopFragment + OnCommitCallbackOp.
  • TabsContainer: null-safe selectedTabOrNull for the veto query (reachable after the selected tab was removed).

Test plan

Repro / regression: apps/src/tests/issue-tests/TestNestedStackPreventSystemBack.tsx (launch directly via apps/App.tsx): push NestedStack → system back intercepted (toast) → background + foreground → system back still intercepted. Push NestedA → system back pops only NestedA, before and after backgrounding.

Regression scenarios: apps/src/tests/single-feature-tests/stack-v5/test-stack-prevent-native-dismiss-single-stack/scenario.md and .../test-stack-prevent-native-dismiss-nested-stack/scenario.md (all steps, plus a background cycle after an interception), root-exit block with a single preventing screen, predictive back gesture where nothing vetoes, tabs smoke (test-stack-tabs-stack-in-tabs-base-navigation).

Detox: FabricExample/e2e/single-feature-tests/stack-v5/test-stack-prevent-native-dismiss-{single,nested}-stack.e2e.ts.

Status: static checks green (yarn lint-android, assembleDebug). On-device verification and the scenario/doc-comment updates are pending, hence draft.

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • For API changes, updated relevant public types.
  • Ensured that CI passes

🤖 Generated with Claude Code

https://claude.ai/code/session_01AqrHqNcfhAe6Epx5XGhMWB

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kkafar kkafar changed the title @kkafar/prevent native dismiss system back fix(Android, Stack v5): preventNativeDismiss survives activity restart Sep 3, 2026
kkafar and others added 3 commits September 4, 2026 11:20
…activity restart

preventNativeDismiss silently stops intercepting the system back press
after the activity goes through a stop/start cycle (backgrounding, lock
screen). Root cause: PreventNativeDismissCallback is registered once with
the non-lifecycle addCallback overload and keeps its dispatcher deque
position forever, while every FragmentManager re-inserts its internal
callback at the deque tail on each ON_START - after one cycle the
FragmentManager callbacks leapfrog every prevent callback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WoagDFFRUJArVSTaMW83X
…vity restarts

Each StackContainer now owns a single veto-only OnBackPressedCallback,
registered with the lifecycle owner of its FragmentManager (the hosting
fragment when nested, the FragmentActivity host when at the root). Both
callbacks are lifecycle-owned, so on every activity stop/start they are
re-inserted into the dispatcher in a fixed order: FragmentManager's pop
callback first, the veto right after it. Previously each
StackScreenFragment registered its own callback with the non-lifecycle
overload, which kept its deque slot forever while every FragmentManager
callback moved to the tail on ON_START - one background cycle was enough
for the FragmentManager to leapfrog the prevent callback and pop the
screen.

System back now resolves through the same recursive
wantsToPreventStackNativeDismiss() query as the header chevron. The
per-fragment callback, its change observer and the top-fragment
bookkeeping (updateTopFragment, OnCommitCallbackOp) are removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqrHqNcfhAe6Epx5XGhMWB
The task of a StackContainer is not to emit an react event, it is just
to "handle" the prevention.
New name does not leak implementation details.
@kkafar
kkafar force-pushed the @kkafar/prevent-native-dismiss-system-back branch from f7996d7 to 74d6a69 Compare September 4, 2026 09:27
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