Skip to content

refactor(tabs): split native nav state into state and update request#3950

Merged
kkafar merged 8 commits intomainfrom
@kkafar/tabs-allow-for-programmatic-native-tab-changes
Apr 30, 2026
Merged

refactor(tabs): split native nav state into state and update request#3950
kkafar merged 8 commits intomainfrom
@kkafar/tabs-allow-for-programmatic-native-tab-changes

Conversation

@kkafar
Copy link
Copy Markdown
Member

@kkafar kkafar commented Apr 28, 2026

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 navStaterequest).
  • 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.selectedKeyselectedScreenKey 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

@kkafar kkafar marked this pull request as ready for review April 28, 2026 18:47
@kkafar kkafar requested review from Copilot, kligarski, kmichalikk and t0maboro and removed request for Copilot and t0maboro April 28, 2026 18:47
@kkafar kkafar requested a review from t0maboro April 28, 2026 18:49
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 iOS tabs JS-driven navigation update flow by introducing a dedicated “state update request” type so the controller can propagate the request’s actionOrigin rather than hardcoding it.

Changes:

  • Introduces RNSTabsNavigationStateUpdateRequest (selected key, base provenance, action origin) and threads it through the JS-driven update pipeline.
  • Retypes pending updates and rejection plumbing (controller ↔ component view ↔ event emitter) to use the new request type, including rejection payload fields.
  • Minor formatting-only changes in RNSScreenStackHeaderConfig.mm.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ios/tabs/host/RNSTabsNavigationState.h Adds the new request type and organizes related enums/types.
ios/tabs/host/RNSTabsNavigationState.mm Implements RNSTabsNavigationStateUpdateRequest utilities (init, cloneRequest, factory).
ios/tabs/host/RNSTabBarController.h Retypes delegate + pending-update API to accept the request type.
ios/tabs/host/RNSTabBarController.mm Stores pending updates as requests; forwards request actionOrigin on success; uses baseProvenance for staleness checks and rejection callbacks.
ios/tabs/host/RNSTabsHostComponentView.h Retypes navStateRequest to the request type.
ios/tabs/host/RNSTabsHostComponentView.mm Constructs requests from props (ProgrammaticJs) and forwards cloned requests; updates rejection delegate signature/payload.
ios/tabs/host/RNSTabsHostEventEmitter.h Renames/retypes rejected payload field to rejectedRequest.
ios/tabs/host/RNSTabsHostEventEmitter.mm Emits rejection payload using rejectedRequest.selectedScreenKey / baseProvenance.
ios/RNSScreenStackHeaderConfig.mm Formatting-only line wrapping/indentation changes.

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

Comment thread ios/RNSScreenStackHeaderConfig.mm
kkafar added a commit that referenced this pull request Apr 29, 2026
Mirrors the iOS split landed in PR #3950. `TabsNavState` previously
served as both the authoritative current state and the JS-driven update
request, with `provenance` doing double duty as both "current generation"
and "base provenance the request was derived from". Splits out
`TabsNavStateUpdateRequest(selectedScreenKey, baseProvenance,
actionOrigin)` as a sibling type.

Origin assignment moves from the container to the request-construction
site (`TabsHostViewManager.setNavStateRequest`). The container becomes
origin-agnostic on the JS-driven path: `onMenuItemSelected` reads
`actionOrigin` off the buffered `TabSelectOp.request` instead of
hardcoding `PROGRAMMATIC_JS`. Today only `PROGRAMMATIC_JS` flows through,
so runtime behavior is unchanged — but future native-programmatic
origins can land here without touching the container.

Also bundles a `TabsNavState.selectedKey -> selectedScreenKey` rename
for consistency with the JS contract and the new request type. JS-facing
WritableMap keys (`selectedScreenKey`, `rejectedScreenKey`) were already
named correctly; this only aligns the Kotlin-internal field name.
@kkafar kkafar changed the title refactor(tabs): split iOS nav state into state and update request refactor(tabs): split native nav state into state and update request Apr 29, 2026
Base automatically changed from @kkafar/rename-is-native-action to main April 30, 2026 08:35
kkafar added 7 commits April 30, 2026 10:36
The JS-driven update path was using a single type, RNSTabsNavigationState,
to represent both the controller's authoritative state
(selectedScreenKey + provenance) and a JS request to change it
(target screenKey + baseProvenance). With actionOrigin now part of the
public event API and the design needing room for more origins
(notably native-programmatic in the future), the request needs to
carry its own origin alongside its base provenance.

Flesh out the previously-stub `RNSTabsNavigationStateUpdateRequest`
with screenKey, baseProvenance, and actionOrigin, plus a utility
surface (designated init, cloneRequest, static factory) mirroring
RNSTabsNavigationState. Thread it through:

- `RNSTabsHostComponentView` constructs the request from props with
  `actionOrigin = ProgrammaticJs` and forwards it to the controller.
- `RNSTabBarController.setPendingNavigationStateUpdate:` and the
  ivar (renamed `_pendingOperation` → `_pendingStateUpdate`) take
  the new type. The controller's success-path context now reads
  `actionOrigin` from the pending request instead of hardcoding
  `ProgrammaticJs`, so the controller becomes origin-agnostic on
  this path.
- `isNavigationStateUpdateStale:` reads `baseProvenance` from the
  request.
- The rejection event payload (`OnTabSelectionRejectedPayload`) and
  the corresponding delegate parameter are retyped from
  `RNSTabsNavigationState *rejectedNavState` to
  `RNSTabsNavigationStateUpdateRequest *rejectedRequest`. The JSI
  emit reads `screenKey` / `baseProvenance` from the request.

Scope is iOS only; no JS / Android / codegen-spec changes. The
runtime value of `actionOrigin` on the public event is unchanged
today (still `'programmatic-js'` for JS-driven updates) — this is
purely an internal architectural shift that opens the door to
distinguishing native-programmatic origins later.
Align `RNSTabsNavigationStateUpdateRequest`'s field name with both
the public JS contract (`TabsHostNavStateRequest.selectedScreenKey`)
and the sibling `RNSTabsNavigationState.selectedScreenKey`. The
inconsistency was a leftover from when the request type was first
sketched in isolation.

Touches the property, the designated initializer + static factory
selector labels, the ivar, and the three call sites that read it
(component view rejection assertion, controller pending-update
read, event emitter JSI emit). No semantic change.
I've decided to check this in, because it reappears after every single commit.
Mirrors the iOS split landed in PR #3950. `TabsNavState` previously
served as both the authoritative current state and the JS-driven update
request, with `provenance` doing double duty as both "current generation"
and "base provenance the request was derived from". Splits out
`TabsNavStateUpdateRequest(selectedScreenKey, baseProvenance,
actionOrigin)` as a sibling type.

Origin assignment moves from the container to the request-construction
site (`TabsHostViewManager.setNavStateRequest`). The container becomes
origin-agnostic on the JS-driven path: `onMenuItemSelected` reads
`actionOrigin` off the buffered `TabSelectOp.request` instead of
hardcoding `PROGRAMMATIC_JS`. Today only `PROGRAMMATIC_JS` flows through,
so runtime behavior is unchanged — but future native-programmatic
origins can land here without touching the container.

Also bundles a `TabsNavState.selectedKey -> selectedScreenKey` rename
for consistency with the JS contract and the new request type. JS-facing
WritableMap keys (`selectedScreenKey`, `rejectedScreenKey`) were already
named correctly; this only aligns the Kotlin-internal field name.
@kkafar kkafar force-pushed the @kkafar/tabs-allow-for-programmatic-native-tab-changes branch from fefd025 to 0d7d7fb Compare April 30, 2026 08:36
@kkafar kkafar merged commit 0683933 into main Apr 30, 2026
7 of 8 checks passed
@kkafar kkafar deleted the @kkafar/tabs-allow-for-programmatic-native-tab-changes branch April 30, 2026 08:58
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