Skip to content

refactor(tabs): replace isNativeAction with actionOrigin enum#3949

Merged
kkafar merged 6 commits intomainfrom
@kkafar/rename-is-native-action
Apr 30, 2026
Merged

refactor(tabs): replace isNativeAction with actionOrigin enum#3949
kkafar merged 6 commits intomainfrom
@kkafar/rename-is-native-action

Conversation

@kkafar
Copy link
Copy Markdown
Member

@kkafar kkafar commented Apr 28, 2026

Description

Replaces the boolean isNativeAction field on the onTabSelected event with a three-valued actionOrigin discriminator ('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 / RNSTabBarController on Android & iOS respectively). It now recognizes different sources including the programmatic-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 RNSTabsNavigationStateUpdateSource taxonomy into a single shared enum (RNSTabsActionOrigin).

Changes

  • Public TS API: isNativeAction: booleanactionOrigin: 'user' | 'programmatic-js' | 'implicit' on TabSelectedEvent (and the matching codegen specs for iOS / Android native components).
  • iOS:
    • New RNSTabsActionOrigin enum (User, ProgrammaticJs, Implicit) declared in RNSTabsNavigationState.h.
    • Removed the now-redundant RNSTabsNavigationStateUpdateSource enum; progressNavigationState:withSource: becomes progressNavigationState:withOrigin:.
    • RNSTabsNavigationStateUpdateContext.isNativeActionactionOrigin; propagated through OnTabSelectedPayload and the event emitter, with an Obj-C → codegen-enum conversion in RNSConversions-Tabs.mm.
    • The implicit reconciliation path (More-controller size-class transition) now reports Implicit instead of being lumped into isNativeAction = YES.
  • Android:
    • New TabsActionOrigin enum (USER, PROGRAMMATIC_JS) under gamma.tabs.container. 'implicit' is iOS-only at the moment and not currently produced on Android — documented in the enum's KDoc.
    • Threaded through TabsContainerDelegate.onNavStateUpdate, TabsHostEventEmitter, and TabsHostTabSelectedEvent (event payload key actionOrigin, serialized via enum.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:

  • iOS — tab bar tap → actionOrigin === 'user'.
  • iOS — JS-driven navStateRequest update → actionOrigin === 'programmatic-js'.
  • iOS — iPad horizontal size-class transition that collapses/expands the More controller → actionOrigin === 'implicit' (previously isNativeAction: true, now distinguishable).
  • Android — tab bar tap → actionOrigin === 'user'; JS-driven update → actionOrigin === 'programmatic-js'.

Reproduction lives in FabricExample Tabs test screens; log e.nativeEvent.actionOrigin from onTabSelected.

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 added 3 commits April 28, 2026 18:28
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).
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 TabsActionOrigin and 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.

Comment thread ios/conversion/RNSConversions-Tabs.mm
Comment thread src/components/tabs/host/TabsHost.types.ts Outdated
Comment thread ios/tabs/host/RNSTabBarController.mm
Comment on lines +3 to +4
import com.swmansion.rnscreens.gamma.tabs.container.TabsActionOrigin.PROGRAMMATIC_JS
import com.swmansion.rnscreens.gamma.tabs.container.TabsActionOrigin.USER
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.

not needed, this file defines these

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

* @summary Origin (actor) that requested this tab transition.
*
* @description
* - `'user'` — direct native UI interaction (e.g. tab bar tap, iOS tab drag-and-drop).
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.

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?

Copy link
Copy Markdown
Member Author

@kkafar kkafar Apr 30, 2026

Choose a reason for hiding this comment

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

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.

kkafar added 3 commits April 30, 2026 10:12
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.
@kkafar kkafar merged commit 6f65cc3 into main Apr 30, 2026
8 checks passed
@kkafar kkafar deleted the @kkafar/rename-is-native-action branch April 30, 2026 08:35
kkafar added a commit that referenced this pull request Apr 30, 2026
…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
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.

4 participants