fix(Android, Tabs): propagate actionOrigin into navigation state progression#3996
Merged
kkafar merged 3 commits intoMay 8, 2026
Merged
Conversation
Contributor
There was a problem hiding this comment.
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
actionOriginonce inonMenuItemSelectedand pass it throughupdateSelectedFragmentandprogressNavigationState. - Update
lastUINavStatebased onactionOrigin(rather thanisInExternalOperationContext) to correctly advance UI-authoritative state forPROGRAMMATIC_NATIVE. - Restrict the “prevent native selection” gate to only apply to
USERactions.
Comments suppressed due to low confidence (1)
android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt:488
updateSelectedFragmentnow takesactionOrigin, but in thenavState.isEmpty()(first-selection) branch the origin isn’t used andlastUINavStateis never updated. This means a first selection coming fromPROGRAMMATIC_NATIVE(orUSER) will leavelastUINavStateasEMPTY, soisNavigationStateStalewill keep returning false and stale JS updates can slip through. Consider updatinglastUINavStatewhen initializingnavStatefor any origin other thanPROGRAMMATIC_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.
t0maboro
approved these changes
May 8, 2026
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.
99f0ecb to
6e436cd
Compare
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
TabsContainer(Android, gamma) was deciding several things offisInExternalOperationContexteven though the real intent was to gate on the action'sactionOrigin. The two were aligned only by accident —isInExternalOperationContext == true⇔actionOrigin ∈ { PROGRAMMATIC_JS, PROGRAMMATIC_NATIVE }— which madePROGRAMMATIC_NATIVEupdates behave incorrectly on Android compared to iOS:progressNavigationStatedid not haveactionOriginavailable, so consumers observing the progression could not distinguish user-driven from external/programmatic transitions.lastUINavStatewas not updated forPROGRAMMATIC_NATIVE, even though that origin represents a native-authoritative state change (downstream library callingsubmitSelectionOfTabsScreenWithKey). This caused subsequent JS requests with stalebaseProvenanceto slip throughisNavigationStateStalechecks instead of being rejected.!isInExternalOperationContext, which incidentally also rejectedPROGRAMMATIC_NATIVEupdates. Per intent (and matching iOS), this gate should reject onlyUSERactions.This PR threads the resolved
actionOriginthrough the relevant code paths and switches each gate to test the origin directly. iOS and Android now agree on semantics.Side note:
isPreventNativeSelectionEnabledis misnamed — it really means "prevent user selection". Renaming it deferred until a future major release.Changes
actionOriginonce inonMenuItemSelected, before any gate that depends on it.actionOriginthroughupdateSelectedFragmentandprogressNavigationState.lastUINavStatenow updates wheneveractionOrigin != PROGRAMMATIC_JS(was: whenever!isInExternalOperationContext).actionOrigin == USER(was:!isInExternalOperationContext).Test plan
Manual reproduction on Android via
FabricExample:PROGRAMMATIC_NATIVEno longer rejected by prevent-selection. From a downstream-library code path (or a temporary call site) invoketabsContainer.submitSelectionOfTabsScreenWithKey(key)on aTabsScreenwhoseisPreventNativeSelectionEnabled = true. Before: selection blocked,onNavigationStateUpdatePreventedemitted. After: selection applied, no prevention emitted.lastUINavStateadvances forPROGRAMMATIC_NATIVE. After the above selection, dispatch a JSnavStateRequestwith the pre-native-updatebaseProvenance. Before: request applied (stale not detected). After: request rejected as stale viaisNavigationStateStale.actionOriginreaches observers during progression. Register aTabsNavigationStateObserverand verifyactionOriginondispatchOnNativeStateChangefor each of: native tab tap (USER),navStateRequestfrom JS (PROGRAMMATIC_JS),submitSelectionOfTabsScreenWithKey(PROGRAMMATIC_NATIVE).isPreventNativeSelectionEnabled = trueis still prevented and emitsonNavigationStateUpdatePrevented.No automated tests exist for this path yet; recommend adding one matrix test in a follow-up.
Checklist