Skip to content

feat(Android, Stack v5): prevent native dismiss support#3620

Open
kkafar wants to merge 10 commits intomainfrom
@kkafar/android-prevent-native-dismiss
Open

feat(Android, Stack v5): prevent native dismiss support#3620
kkafar wants to merge 10 commits intomainfrom
@kkafar/android-prevent-native-dismiss

Conversation

@kkafar
Copy link
Member

@kkafar kkafar commented Feb 4, 2026

Description

Add initial implementation of prevent native dismiss on Android
This commit adds support for "prevent native dismiss" behavior on
Android for stack screens.

The added interface consists of prop on StackScreen:
preventNativeDismiss and event onNativeDismissPrevented, which
lets the programmer react to the native dismiss attempt.

This implementation follows RFC-0717.
It does not handle JS-side of the mechanism.

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

Changes

This is achieved by leveraging a couple of native mechanisms.
First, I lean into usage of OnBackPressedCallback AndroidX API, which
lets us handle this very gracefully & hopefully correct on many
SDK levels.

Note

TODO: The behavior on lower SDK levels has not been tested yet.

Now, for each StackScreenFragment we add a dedicated callback, which
is enabled or disabled depending on the value. I do not add/remove the
callbacks because their order in the OnBackPressedDispatcher matter,
and we can add a callback only to the end of the list.

Caution

This implementation assumes that the fragments will move to created
state in the order they are in the fragment manager (stack order).
If this ever turns out to not be the case, we'll need to modify this
implementation.

Beside above ☝🏻 the added code also introduces "top-fragment" mechanism.
It is necessary to prevent non-top-fragment from blocking native dismiss.
Likely, when we introduce proper attach/detach mechanism for "non visible" fragments we will be able to get rid of this new mechanism, but currently it is necessary. Please see commit description: 1212f36

Add missing "exported custom direct event type constants"
I don't know how our events have worked so far. I thought that
overriding this method is obligatory. Apparently not. Maybe its
just hint for the React machinery. I'm not sure right now,
but I've verified that this code is indeed called & React gathers
this information.

Visual documentation

Screen.Recording.2026-02-05.at.16.00.28.mov

Test plan

These do not test "dynamic" case. It requires a bit more setup in StackContainer code that is too much outside of the scope of this PR. I'll work on it separately.

prevent-native-dismiss-single-stack
prevent-native-dismiss-nested-stack

Checklist

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

I don't know how our events have worked so far. I thought that
overriding this method is obligatory. Apparently not. Maybe its
just hint for the React machinery. I'm not sure right now,
but I've verified that this code is indeed called & React gathers
this information.
This commit adds support for "prevent native dismiss" behavior on
Android for stack screens.

The added interface consists of prop on `StackScreen`:
`preventNativeDismiss` and event `onNativeDismissPrevented`, which
lets the programmer react to the dismiss attempt.

This is achieved by leveraging a couple of native mechanisms.
First, I lean into usage of `OnBackPressedCallback` AndroidX API, which
lets us handle this very gracefully & hopefully correct on many
SDK levels.

> [!note]
> TODO: The behavior on lower SDK levels has not been tested yet.

Now, for each `StackScreenFragment` we add a dedicated callback, which
is enabled or disabled depending on the value. I do not add/remove the
callbacks because their order in the `OnBackPressedDispatcher` matter,
and we can add a callback only to the end of the list.

> [!caution]
> This implementation assumes that the fragments will move to `created`
> state in the order they are in the fragment manager (stack order).
> If this ever turns out to not be the case, we'll need to modify this
> implementation.

This implementation follows [ RFC-0717 ](https://github.com/software-mansion/react-native-screens-labs/blob/main-issue-tracker/rfcs/0717-stack-prevent-dismiss.md)
@kkafar kkafar marked this pull request as draft February 4, 2026 17:31
I've made StackContainer expose routeOptions,
so that it is easier to read what current options applied to scree are.
Usually in production-grade navigator, this could not be a best idea,
however in our case I think it is pretty useful.

Also, I don't test "dynamic" case yet, where the value is changed for a
screen depending on a condition. This would require way more
infrastructure code, which we do not have yet. I intend to add this
later.
stack

Previous code has not covered that case, because it assumed, that the
callback will disable itself when fragment lifecycle reaches CREATED.
However, we do not update trigger lifecycle changes for non-top
fragments **yet**, therefore currently the callback isEnabled state
dependent solely on the configuration value sourced from the screen.
This led to situation, where a callback from a non-top fragment would be
enabled & would prevent native dismiss.

This commit changes that.
First I decided to stay with approach that each fragment owns a
`OnBackPressedCallback`. Alternative would be to have only a single
callback owned by `StackContainer`, however registering a callback
requires access to `OnBackPressedDispatcher` which is owned by the
activity. `StackContainer` does not have easy access to activity.
I figure, that we could either retrieve it from one of the fragments
after it is attached to the activity, or from `reactContext`, but
currently `StackContainer` is not aware of excessive React Native
symbols directly & I want to keep it like that. Therefore keeping the
callbacks in fragment allows for their easier lifecycle management.

Regardless of above considerations, we need now to have the "top
fragment" distinguished - and this is mostly what this commit does.
Every time, we update primary navigation fragment we now also denote it
as the "top fragment".
Please note, that I use fragment manager state to determine the
top-fragment. This is done deliberately! `stackModel` can be out of sync
with the fragment manager state, e.g. by already having additional
fragments for which the transactions have not yet been executed &
therefore it can have fragments which aren't yet attached to fragment
manager.

I dislike two things this commit does:

1. it moves the `isTopFragment` to the fragment itself. I think this
   state should be, if at all, kept inside the `StackContainer`, where
   there is less risk that it will become out-of-sync (e.g. two top
   fragments).
2. The updateTopFragment code is not tightly coupled with
   SetPrimaryNavFragmentOp - I add a callback in every call site. I
   think right now that these two should be coupled.

Finally, we should reconsider this approach once we have proper
attach/detach system for "invisible" fragments. Please note, however,
that it will still need to have some "top-fragment" mechanism, because
if we e.g. top-screen will be transparent, we won't want the second-top
screen to be detached & its callback will still block native dismiss,
when clearly it shouldn't.
@kkafar kkafar marked this pull request as ready for review February 5, 2026 15:29
@kkafar kkafar requested a review from t0maboro February 5, 2026 15:29
@kkafar
Copy link
Member Author

kkafar commented Feb 5, 2026

This is how I use the test for now:

import React from 'react';
import { enableFreeze } from 'react-native-screens';
import Example from './Example';
// import * as Test from './src/tests/issue-tests';
import { COMPONENT_SCENARIOS } from './src/tests/single-feature-tests';

enableFreeze(true);

export default function App() {
  const Component = COMPONENT_SCENARIOS.StackHost[0].screen;
  return <Component />;
  // return <Test.TestStackNesting />;
}

Currently it is not reachable via Example app, because there are some bugs in the example app code I need to investigate. Will do it separately, its out of the scope of this PR

Interesting behavior here is that native pop of nested stack is blocked
by two screens: the last one in nested stack & the hosting one.
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