Skip to content

fix(Android, Tabs): propagate actionOrigin into navigation state progression#3996

Merged
kkafar merged 3 commits into
mainfrom
@kkafar/fix-invalid-origin-passed-to-progress-state-android
May 8, 2026
Merged

fix(Android, Tabs): propagate actionOrigin into navigation state progression#3996
kkafar merged 3 commits into
mainfrom
@kkafar/fix-invalid-origin-passed-to-progress-state-android

Conversation

@kkafar

@kkafar kkafar commented May 8, 2026

Copy link
Copy Markdown
Member

Description

TabsContainer (Android, gamma) was deciding several things off isInExternalOperationContext even though the real intent was to gate on the action's actionOrigin. The two were aligned only by accident — isInExternalOperationContext == trueactionOrigin ∈ { PROGRAMMATIC_JS, PROGRAMMATIC_NATIVE } — which made PROGRAMMATIC_NATIVE updates behave incorrectly on Android compared to iOS:

  1. progressNavigationState did not have actionOrigin available, so consumers observing the progression could not distinguish user-driven from external/programmatic transitions.
  2. lastUINavState was not updated for PROGRAMMATIC_NATIVE, even though that origin represents a native-authoritative state change (downstream library calling submitSelectionOfTabsScreenWithKey). This caused subsequent JS requests with stale baseProvenance to slip through isNavigationStateStale checks instead of being rejected.
  3. The "prevent native selection" gate fired on !isInExternalOperationContext, which incidentally also rejected PROGRAMMATIC_NATIVE updates. Per intent (and matching iOS), this gate should reject only USER actions.

This PR threads the resolved actionOrigin through the relevant code paths and switches each gate to test the origin directly. iOS and Android now agree on semantics.

Side note: isPreventNativeSelectionEnabled is misnamed — it really means "prevent user selection". Renaming it deferred until a future major release.

Changes

  • Compute actionOrigin once in onMenuItemSelected, before any gate that depends on it.
  • Thread actionOrigin through updateSelectedFragment and progressNavigationState.
  • lastUINavState now updates whenever actionOrigin != PROGRAMMATIC_JS (was: whenever !isInExternalOperationContext).
  • Prevent-native-selection gate now checks actionOrigin == USER (was: !isInExternalOperationContext).

Test plan

Manual reproduction on Android via FabricExample:

  1. PROGRAMMATIC_NATIVE no longer rejected by prevent-selection. From a downstream-library code path (or a temporary call site) invoke tabsContainer.submitSelectionOfTabsScreenWithKey(key) on a TabsScreen whose isPreventNativeSelectionEnabled = true. Before: selection blocked, onNavigationStateUpdatePrevented emitted. After: selection applied, no prevention emitted.
  2. lastUINavState advances for PROGRAMMATIC_NATIVE. After the above selection, dispatch a JS navStateRequest with the pre-native-update baseProvenance. Before: request applied (stale not detected). After: request rejected as stale via isNavigationStateStale.
  3. actionOrigin reaches observers during progression. Register a TabsNavigationStateObserver and verify actionOrigin on dispatchOnNativeStateChange for each of: native tab tap (USER), navStateRequest from JS (PROGRAMMATIC_JS), submitSelectionOfTabsScreenWithKey (PROGRAMMATIC_NATIVE).
  4. Regression check. Standard tab tap on a tab with isPreventNativeSelectionEnabled = true is still prevented and emits onNavigationStateUpdatePrevented.

No automated tests exist for this path yet; recommend adding one matrix test in a follow-up.

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

@kkafar kkafar changed the title fix(tabs,android): propagate actionOrigin into navigation state progression fix(Android, Tabs): propagate actionOrigin into navigation state progression May 8, 2026
@kkafar kkafar requested a review from Copilot May 8, 2026 09:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aligns Android (gamma) Tabs navigation state progression with iOS by explicitly propagating actionOrigin through the selection/update pipeline, so programmatic vs user-driven transitions are handled consistently and observers can differentiate origins.

Changes:

  • Compute actionOrigin once in onMenuItemSelected and pass it through updateSelectedFragment and progressNavigationState.
  • Update lastUINavState based on actionOrigin (rather than isInExternalOperationContext) to correctly advance UI-authoritative state for PROGRAMMATIC_NATIVE.
  • Restrict the “prevent native selection” gate to only apply to USER actions.
Comments suppressed due to low confidence (1)

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt:488

  • updateSelectedFragment now takes actionOrigin, but in the navState.isEmpty() (first-selection) branch the origin isn’t used and lastUINavState is never updated. This means a first selection coming from PROGRAMMATIC_NATIVE (or USER) will leave lastUINavState as EMPTY, so isNavigationStateStale will keep returning false and stale JS updates can slip through. Consider updating lastUINavState when initializing navState for any origin other than PROGRAMMATIC_JS (or refactor so the initialization path also goes through the same progression logic).
        if (navState.isEmpty()) {
            check(isInExternalOperationContext && pendingStateUpdateRequest != null)
            navState = TabsNavigationState(nextSelectedFragment.requireScreenKey, 0)
            requireFragmentManager
                .createTransactionWithReordering()
                .add(contentView.id, nextSelectedFragment)
                .commitNowAllowingStateLoss()
            return true

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@kkafar kkafar marked this pull request as ready for review May 8, 2026 11:04
kkafar added 3 commits May 8, 2026 13:05
Compute actionOrigin before updateSelectedFragment instead of only at the
dispatchOnNativeStateChange call site, and pass it down through
updateSelectedFragment and progressNavigationState. Previously the origin
was unavailable when progressing navigation state, so consumers observing
the progression could not distinguish user vs. external/programmatic
actions.
TabsActionOrigin.PROGRAMMATIC_NATIVE

isInExternalOperationContext == true iff actionOrigin ===
PROGRAMMATIC_NATIVE || actionOrigin === USER.

This is misaligned with our logic on iOS.

This commit aligns it.
The intended behavior of this mechanism is to reject only user-driven actions.
I figure out now, that the `isPreventNativeSelectionEnabled` prop naming
is wrong, but I guess we won't change this before 5.0.
It should be rather named `isPreventUserSelectionEnabled` or something
like that.
@kkafar kkafar force-pushed the @kkafar/fix-invalid-origin-passed-to-progress-state-android branch from 99f0ecb to 6e436cd Compare May 8, 2026 11:05
@kkafar kkafar merged commit 2abeaa3 into main May 8, 2026
4 of 5 checks passed
@kkafar kkafar deleted the @kkafar/fix-invalid-origin-passed-to-progress-state-android branch May 8, 2026 11:12
kkafar added a commit that referenced this pull request May 11, 2026
…ression (#3996)

## Description

`TabsContainer` (Android, gamma) was deciding several things off
`isInExternalOperationContext` even though the real intent was to gate
on the action's `actionOrigin`. The two were aligned only by accident —
`isInExternalOperationContext == true` ⇔ `actionOrigin ∈ {
PROGRAMMATIC_JS, PROGRAMMATIC_NATIVE }` — which made
`PROGRAMMATIC_NATIVE` updates behave incorrectly on Android compared to
iOS:

1. `progressNavigationState` did not have `actionOrigin` available, so
consumers observing the progression could not distinguish user-driven
from external/programmatic transitions.
2. `lastUINavState` was not updated for `PROGRAMMATIC_NATIVE`, even
though that origin represents a native-authoritative state change
(downstream library calling `submitSelectionOfTabsScreenWithKey`). This
caused subsequent JS requests with stale `baseProvenance` to slip
through `isNavigationStateStale` checks instead of being rejected.
3. The "prevent native selection" gate fired on
`!isInExternalOperationContext`, which incidentally also rejected
`PROGRAMMATIC_NATIVE` updates. Per intent (and matching iOS), this gate
should reject only `USER` actions.

This PR threads the resolved `actionOrigin` through the relevant code
paths and switches each gate to test the origin directly. iOS and
Android now agree on semantics.

Side note: `isPreventNativeSelectionEnabled` is misnamed — it really
means "prevent user selection". Renaming it deferred until a future
major release.

## Changes

- Compute `actionOrigin` once in `onMenuItemSelected`, before any gate
that depends on it.
- Thread `actionOrigin` through `updateSelectedFragment` and
`progressNavigationState`.
- `lastUINavState` now updates whenever `actionOrigin !=
PROGRAMMATIC_JS` (was: whenever `!isInExternalOperationContext`).
- Prevent-native-selection gate now checks `actionOrigin == USER` (was:
`!isInExternalOperationContext`).

## Test plan

Manual reproduction on Android via `FabricExample`:

1. **`PROGRAMMATIC_NATIVE` no longer rejected by prevent-selection.**
From a downstream-library code path (or a temporary call site) invoke
`tabsContainer.submitSelectionOfTabsScreenWithKey(key)` on a
`TabsScreen` whose `isPreventNativeSelectionEnabled = true`. Before:
selection blocked, `onNavigationStateUpdatePrevented` emitted. After:
selection applied, no prevention emitted.
2. **`lastUINavState` advances for `PROGRAMMATIC_NATIVE`.** After the
above selection, dispatch a JS `navStateRequest` with the
pre-native-update `baseProvenance`. Before: request applied (stale not
detected). After: request rejected as stale via
`isNavigationStateStale`.
3. **`actionOrigin` reaches observers during progression.** Register a
`TabsNavigationStateObserver` and verify `actionOrigin` on
`dispatchOnNativeStateChange` for each of: native tab tap (`USER`),
`navStateRequest` from JS (`PROGRAMMATIC_JS`),
`submitSelectionOfTabsScreenWithKey` (`PROGRAMMATIC_NATIVE`).
4. **Regression check.** Standard tab tap on a tab with
`isPreventNativeSelectionEnabled = true` is still prevented and emits
`onNavigationStateUpdatePrevented`.

No automated tests exist for this path yet; recommend adding one matrix
test in a follow-up.

## 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

(cherry picked from commit 2abeaa3)
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.

3 participants