refactor(tabs): replace isNativeAction with actionOrigin enum#3949
refactor(tabs): replace isNativeAction with actionOrigin enum#3949
Conversation
TabSelectedEvent previously carried a boolean `isNativeAction`, collapsing two distinct origins of native-driven tab selections (user tap, UIKit reshuffle on size-class transition) into the same value. Replace it with `actionOrigin: 'user' | 'js' | 'implicit'`, matching the origin set defined in RFC-1028. iOS already distinguishes all three internally — the `implicit` case is now exposed instead of being collapsed into `true`. Android emits only `'user'` and `'js'`; no `'implicit'` code path exists there today. While here, consolidate the Obj-C origin model: the existing `RNSTabsNavigationStateUpdateSource` enum is renamed to `RNSTabsActionOrigin` (cases `User`, `ProgrammaticJs`, `Implicit`) so we have a single representation across internal state transitions and the event payload. The private method `progressNavigationState:withSource:` becomes `…withOrigin:`. No JS/RN consumers (incl. the react-navigation submodule) read the old field today, so this is a contract change with no behavior fallout.
Align the public `actionOrigin` enum value with the Obj-C/codegen naming. `'js'` was ambiguous; `'programmatic-js'` makes it explicit that this origin covers JS-driven navigation requests delivered via the `navStateRequest` prop, distinct from a generic "JS" label. Updates the TS public type and both codegen specs, the Kotlin enum (`JS` → `PROGRAMMATIC_JS`) and its `toString()` mapping, and the iOS conversion helper to return the codegen-generated `ProgrammaticJs` C++ case (RN codegen converts `'programmatic-js'` kebab-case to `ProgrammaticJs` PascalCase).
There was a problem hiding this comment.
Pull request overview
Refactors the onTabSelected event payload across the TS surface and native iOS/Android implementations by replacing the lossy isNativeAction: boolean with a more expressive actionOrigin discriminator, enabling consumers to distinguish user, JS-programmatic, and implicit (iOS-only) transitions.
Changes:
- Updated the public TS event type and both iOS/Android Fabric codegen specs to expose
actionOrigin: 'user' | 'programmatic-js' | 'implicit'. - iOS: introduced
RNSTabsActionOrigin, threaded it through navigation state update context, and converted it to the codegen event enum in the iOS event emitter. - Android: added
TabsActionOriginand threaded it through container/delegate/emitter/event serialization (string payload).
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/fabric/tabs/TabsHostIOSNativeComponent.ts | Updates iOS Fabric spec event payload to actionOrigin. |
| src/fabric/tabs/TabsHostAndroidNativeComponent.ts | Updates Android Fabric spec event payload to actionOrigin. |
| src/components/tabs/host/TabsHost.types.ts | Public TS API now documents and exposes actionOrigin. |
| ios/tabs/host/RNSTabsNavigationState.h | Adds RNSTabsActionOrigin, replaces isNativeAction in update context. |
| ios/tabs/host/RNSTabsNavigationState.mm | Implements update context initialization with actionOrigin. |
| ios/tabs/host/RNSTabsHostEventEmitter.h | Updates OnTabSelected payload to carry RNSTabsActionOrigin. |
| ios/tabs/host/RNSTabsHostEventEmitter.mm | Converts native origin enum to codegen enum and emits actionOrigin. |
| ios/tabs/host/RNSTabsHostComponentView.mm | Emits actionOrigin from update context to the event emitter. |
| ios/tabs/host/RNSTabBarController.mm | Replaces source taxonomy with origin; marks implicit reconciliations as Implicit. |
| ios/conversion/RNSConversions.h | Declares conversion function for action origin enum. |
| ios/conversion/RNSConversions-Tabs.mm | Implements ObjC enum → codegen enum conversion for actionOrigin. |
| android/.../TabsHostTabSelectedEvent.kt | Serializes actionOrigin string into the event payload. |
| android/.../TabsHostEventEmitter.kt | Threads actionOrigin through emitter API. |
| android/.../TabsHost.kt | Updates delegate callback plumbing to pass actionOrigin. |
| android/.../TabsContainerDelegate.kt | Updates delegate API docs/signature to actionOrigin. |
| android/.../TabsContainer.kt | Determines origin from external-operation context and forwards it. |
| android/.../TabsActionOrigin.kt | Adds Android enum with stable JS string serialization. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import com.swmansion.rnscreens.gamma.tabs.container.TabsActionOrigin.PROGRAMMATIC_JS | ||
| import com.swmansion.rnscreens.gamma.tabs.container.TabsActionOrigin.USER |
There was a problem hiding this comment.
not needed, this file defines these
There was a problem hiding this comment.
At the first glance I agree, but when I run "optimise imports" action in this file - it does nothing - it qualifies these as used.
I've removed these and the app still builds successfully -> leaving them removed, as you suggested.
| * @summary Origin (actor) that requested this tab transition. | ||
| * | ||
| * @description | ||
| * - `'user'` — direct native UI interaction (e.g. tab bar tap, iOS tab drag-and-drop). |
There was a problem hiding this comment.
I don't think I like user here. My impression is that it's descirbing WHO triggered that, not HOW it happened, whereas others focus on HOW. wdyt about renaming it to user-interaction?
There was a problem hiding this comment.
Good remark, but I see it the other way around. All of those should describe WHO (the origin) triggered that, and not how. But I don't have better naming ideas for the other enum variants. Do you happen to have any suggestions?
I'll proceed here & refactor it later if we come up with some nice naming here.
Weird case. Running "optimise imports" does nothing in that file, it counts those imports as "used". I've removed them & the app built successfully though. Keeping them removed for now.
…3950) ## Description > [!NOTE] > This PR stacks on top of #3949 — please review/land that one first. The base branch here is `@kkafar/rename-is-native-action`, not `main`. The native tabs JS-driven nav state update path was using a single type — `RNSTabsNavigationState` on iOS and `TabsNavState` on Android — to represent two distinct concepts: 1. **Authoritative state** held by the native container: `(selectedScreenKey, provenance)`. 2. **A JS request to change that state** delivered via the `navStateRequest` prop: `(selectedScreenKey, baseProvenance)`. This conflation was tolerable while we only had one origin (`ProgrammaticJs` / `PROGRAMMATIC_JS`), but with the new public `actionOrigin` enum and the need to support more origins in the future (notably native-programmatic), the request needs to carry its own origin alongside its base provenance. The state type is the wrong place for that field — it describes a result, not a request. This PR introduces a sibling `…UpdateRequest` type on both platforms and threads it through the JS-driven update path. The architectural consequence is that the native container becomes **origin-agnostic** on its success path: instead of hardcoding `actionOrigin = ProgrammaticJs` when the update succeeds, it forwards whatever the incoming request carried. The view manager / component view (which knows the origin is `ProgrammaticJs` for prop-driven updates) is now the place that decides. The runtime value of `actionOrigin` on the public event is unchanged today — JS-driven updates still surface `'programmatic-js'`. This is purely an internal architectural shift. ## Changes ### iOS - New `RNSTabsNavigationStateUpdateRequest` type in `RNSTabsNavigationState.h/.mm` with `selectedScreenKey`, `baseProvenance`, `actionOrigin`. Mirrors `RNSTabsNavigationState`'s utility surface (designated init, `cloneRequest`, static factory). - `RNSTabsHostComponentView`: - `navStateRequest` property retyped to `RNSTabsNavigationStateUpdateRequest *`. - `updateProps:oldProps:` constructs the request directly from C++ props with `actionOrigin = RNSTabsActionOriginProgrammaticJs` and forwards a `cloneRequest` to the controller. - The `rejectedStateUpdateTo:` delegate impl reads `request.selectedScreenKey` / `request.baseProvenance` for the rejection event payload. - `RNSTabBarController`: - `setPendingNavigationStateUpdate:` retyped to take the request. - Ivar renamed `_pendingOperation` → `_pendingStateUpdate` and retyped. - Success-path `RNSTabsNavigationStateUpdateContext` now uses `_pendingStateUpdate.actionOrigin` instead of hardcoded `ProgrammaticJs`. - `isNavigationStateUpdateStale:` retyped; reads `.baseProvenance`. - Delegate `rejectedStateUpdateTo:` parameter retyped to the request. - `OnTabSelectionRejectedPayload.rejectedNavState` (in `RNSTabsHostEventEmitter.h`) renamed/retyped to `rejectedRequest: RNSTabsNavigationStateUpdateRequest *`. The JSI emit reads `selectedScreenKey` / `baseProvenance` from the request. ### Android - New `TabsNavStateUpdateRequest` data class in `TabsNavState.kt` with `selectedScreenKey`, `baseProvenance`, `actionOrigin`. - `TabsHostViewManager.setNavStateRequest` constructs the request with `actionOrigin = TabsActionOrigin.PROGRAMMATIC_JS` baked in (instead of just a `TabsNavState`). - `TabsHost.jsNavStateRequest` retyped to `TabsNavStateUpdateRequest?`. `updateJSNavStateRequest` retyped. - `TabSelectOp` retyped to wrap a `TabsNavStateUpdateRequest` (field `navState` → `request`). - `TabsContainer`: - `onMenuItemSelected` no longer hardcodes `PROGRAMMATIC_JS` in the external-context branch — it reads `(pendingOperation as TabSelectOp).request.actionOrigin`. The `isInExternalOperationContext` flag stays (it still suppresses the prevent-native-selection check and the `lastUINavState` capture). - `isNavStateStale` retyped; reads `request.baseProvenance`. - All `performOperation` reads updated to `tabSelectOp.request.…`. - `TabsContainerDelegate.onNavStateUpdateRejected` parameter `rejectedNavState: TabsNavState` retyped to `rejectedRequest: TabsNavStateUpdateRequest`. `TabsHostEventEmitter` and `TabsHostTabSelectionRejectedEvent` follow. - Bundles a `TabsNavState.selectedKey` → `selectedScreenKey` rename for cross-platform consistency with the JS contract and the new request type. JS-facing `WritableMap` keys (`selectedScreenKey`, `rejectedScreenKey`) were already named correctly — only the Kotlin-internal field name moves. No JS, codegen-spec, or conversion-helper changes — public contract untouched. ## Test plan No new automated tests; this is an internal architectural refactor with no behavioral change on the user/JS-driven paths. Manual verification via `FabricExample`: ### iOS - Tab bar tap → `actionOrigin === 'user'` (unchanged path). - JS-driven `navStateRequest` update → `actionOrigin === 'programmatic-js'` (now sourced from the request, not hardcoded in the controller). - iPad horizontal size-class transition forcing UIKit reshuffle → `actionOrigin === 'implicit'` (unchanged path). - Stale JS update (`rejectStaleNavStateUpdates: true`, mid-flight tap) → `onTabSelectionRejected` fires with `rejectedScreenKey` and `rejectedProvenance` matching the request's `selectedScreenKey` and `baseProvenance`. ### Android - Tab bar tap → `actionOrigin === 'user'` (unchanged path). - JS-driven `navStateRequest` update → `actionOrigin === 'programmatic-js'` (now sourced from the request, not hardcoded in the container). - Stale JS update (`rejectStaleNavStateUpdates: true`, mid-flight tap) → `onTabSelectionRejected` fires with `rejectedScreenKey` and `rejectedProvenance` matching the request's `selectedScreenKey` and `baseProvenance`. Reproduction lives in the `FabricExample` Tabs test screens; log `e.nativeEvent.actionOrigin` from `onTabSelected` and `e.nativeEvent.rejectedScreenKey` / `rejectedProvenance` from `onTabSelectionRejected`. ## 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
Description
Replaces the boolean
isNativeActionfield on theonTabSelectedevent with a three-valuedactionOrigindiscriminator ('user' | 'programmatic-js' | 'implicit').Important thing to note here is that this change also enables us to add more action origins in the future, such as native-programmatic actions.
One thing I'm not proud of is that the React model details are leaked into the
TabsContainer(TabsContainer/RNSTabBarControlleron Android & iOS respectively). It now recognizes different sources including theprogrammatic-js.This is not great, but it shouldn't hinder us later when attempting to test it natively.
The boolean was lossy: it conflated JS-driven updates with implicit UIKit-driven selection changes (e.g. iPad horizontal size-class transitions where UIKit reshuffles the selected tab as a side effect). Consumers that needed to react to JS-originated transitions specifically had no way to distinguish them. Promoting the field to an enum makes the actor explicit at the API boundary; on iOS it also lets us collapse the previous internal
RNSTabsNavigationStateUpdateSourcetaxonomy into a single shared enum (RNSTabsActionOrigin).Changes
isNativeAction: boolean→actionOrigin: 'user' | 'programmatic-js' | 'implicit'onTabSelectedEvent(and the matching codegen specs for iOS / Android native components).RNSTabsActionOriginenum (User,ProgrammaticJs,Implicit) declared inRNSTabsNavigationState.h.RNSTabsNavigationStateUpdateSourceenum;progressNavigationState:withSource:becomesprogressNavigationState:withOrigin:.RNSTabsNavigationStateUpdateContext.isNativeAction→actionOrigin; propagated throughOnTabSelectedPayloadand the event emitter, with an Obj-C → codegen-enum conversion inRNSConversions-Tabs.mm.Implicitinstead of being lumped intoisNativeAction = YES.TabsActionOriginenum (USER,PROGRAMMATIC_JS) undergamma.tabs.container.'implicit'is iOS-only at the moment and not currently produced on Android — documented in the enum's KDoc.TabsContainerDelegate.onNavStateUpdate,TabsHostEventEmitter, andTabsHostTabSelectedEvent(event payload keyactionOrigin, serialized viaenum.toString()).Test plan
No new automated tests; this is a rename/refactor of an existing public field with no behavioral change on the user/JS paths. The implicit path on iOS now reports a distinct origin where it previously reported
isNativeAction = YES, so manual verification on the affected platforms:actionOrigin === 'user'.navStateRequestupdate →actionOrigin === 'programmatic-js'.actionOrigin === 'implicit'(previouslyisNativeAction: true, now distinguishable).actionOrigin === 'user'; JS-driven update →actionOrigin === 'programmatic-js'.Reproduction lives in
FabricExampleTabs test screens; loge.nativeEvent.actionOriginfromonTabSelected.Checklist