Skip to content

refactor(Android, Tabs): apply tab title, badge and accessibility only when invalidated - #4975

Merged
kkafar merged 7 commits into
mainfrom
@kkafar/rfc-1823-android-tabs-gate-item-flags
Oct 22, 2026
Merged

kkafar merged 7 commits into
mainfrom
@kkafar/rfc-1823-android-tabs-gate-item-flags

Conversation

@kkafar

@kkafar kkafar commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Requested by Kacper

Description

Follow-up to #4920, raised in its review. The tab item invalidation flags TITLE, BADGE and ACCESSIBILITY only routed a change to the per-item menu update, which then re-applied title (diffed), badge and accessibility every time. They now gate what is applied, mirroring the Stack header's needsRebuild || flags.containsAny(X) in StackHeaderCoordinatorLayout.processUpdate. Nothing visible changes; unchanged parts of a menu item are no longer re-applied.

Closes https://github.com/software-mansion/react-native-screens-labs/issues/1956.

Targets main: #4946, which it was stacked on, merged on 2026-10-21.

Changes

  • One consumer of a menu item's flags (review, e3c6bde): TabsContainer.applyPendingMenuItemChanges is the only caller of TabsMenuItemState.consumePendingFlags(). It hands the flags to TabsAppearanceCoordinator.updateMenuItemAppearance, which applies title (TITLE), icon (ICON / ICON_SIZE), active indicator (ICON_SIZE / ACTIVE_INDICATOR_SIZE) and badge (BADGE), and applies accessibility (ACCESSIBILITY) through TabsHostA11yCoordinator. The title is no longer diffed manually.
  • Apply-only primitives: TabsAppearanceApplicator exposes applyTitle, applyIcon, applyActiveIndicatorSize and updateBadgeAppearance and knows nothing about flags. A part whose menu item view doesn't exist yet is re-invalidated through TabsBottomNavigationView.invalidateMenuItem: the active indicator as before, and accessibility too (ce494b0; setA11yPropertiesToTabItem now reports whether it found the view).
  • Whole-bar update without a special mode: TabsContainer.updateTabBarAppearance applies the bar-wide appearance, then every menu item through the same per-item path, then fonts. It invalidates BADGE on every item first (badge colours come from the selected tab's appearance); appearance updates also invalidate ACCESSIBILITY, replacing setA11yPropertiesToAllTabItems (removed, with TabsHostA11yCoordinator's unused tabsScreenFragments parameter).
  • A fresh menu item state is fully dirty, so new and rebuilt menu items still get everything applied.
  • Menu item lookups go through TabsBottomNavigationView.findMenuItem(menuItemId) / requireMenuItem(menuItemId), and the appearance applicator / coordinator methods are explicitly internal (review, fb1e94a and 1f0d8bd; comment fixes in c339610). No behavioural change, so the device runs below on ce494b0 still apply.

Test plan

Run locally:

  • yarn lint-android (layer check + spotlessCheck) passes.
  • :react-native-screens:compileDebugKotlin succeeds.

On device, after the review changes, this PR alone (ce494b0, without #4976; Android, FabricExample, release build, Pixel 3a API 34 emulator):

  • Detox: every Android-applicable file in FabricExample/e2e/single-feature-tests/tabs/ plus example-tests/bottomTabs: 11 suites, 66 tests pass. Crash buffer empty.
  • Manual runtime change of one tab's title, badgeValue, tabBarItemTestID and tabBarItemAccessibilityLabel (temporary buttons on test-tabs-general-appearance-android, not committed), read with uiautomator dump: the item's title, badge, resource-id and content description update immediately; the other tabs stay unchanged; the values survive switching tabs and whole-bar updates (label visibility labeled / unlabeled); a reset restores the original values. Crash buffer empty.

On device, before the review restructuring (Android, FabricExample, release build with this PR and #4976 on top, Pixel 3a API 34 emulator):

  • Detox: every Android-applicable file in FabricExample/e2e/single-feature-tests/tabs/ plus example-tests/bottomTabs: 11 suites, 66 tests pass (test-tabs-item-badge and test-tabs-navigation-state-search-tab are iOS-gated). Crash buffer empty.
  • Manual single-feature-tests/tabs/test-tabs-item-icon-tint-and-size: runtime icon size (32 / 56 / default), active indicator (96x64 / auto), tinting modes and the badge toggle apply immediately and revert; other tabs stay unchanged.
  • Manual test-tabs-item-icon: asynchronously loaded image icons appear (they arrive outside a React transaction); the selected icon swaps.
  • Manual test-tabs-item-badge: badge colours follow the selected tab on every switch (whole-bar update re-applies all badges).
  • Manual test-tabs-general-appearance-android and issue-tests/TestBottomTabs: tab items expose their testIDs (viewIdResourceName) and content descriptions, unchanged across tab switches and label modes.
  • Manual test-tabs-item-title: titles, colours and per-tab fonts follow selection.

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

Generated by Claude Code

@kkafar kkafar self-assigned this Oct 19, 2026
@kkafar
kkafar added this pull request to stack #4860 October 19, 2026 11:19
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0df3e0a0-126d-4fc3-b920-f21d13a86fe9
📥 Commits

Reviewing files that changed from the base of the PR and between dde9759 and a5d4810.

📒 Files selected for processing: 6
  • android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/appearance/TabsAppearanceApplicator.kt
  • android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/appearance/TabsAppearanceCoordinator.kt
  • android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsBottomNavigationView.kt
  • android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt
  • android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsHostA11yCoordinator.kt
  • android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsMenuItemState.kt

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

📝 Walkthrough

The Android tab container now coordinates bar-wide and per-item appearance updates through pending invalidation flags. Accessibility updates follow the same per-item flow and remain pending when the item view is unavailable.

Changes: Tab appearance updates

Layer / File(s) Summary
Pending menu-item invalidation
android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsMenuItemState.kt, android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsBottomNavigationView.kt
Menu-item state can return and clear pending flags. The bottom navigation view can find menu items and apply supplied invalidation flags to all stored items.
Selective appearance application
android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/appearance/TabsAppearanceApplicator.kt, android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/appearance/TabsAppearanceCoordinator.kt
The coordinator separates bar, font, and menu-item updates. The applicator provides separate title and icon operations. Active-indicator updates are re-invalidated when the menu item view is unavailable.
Container and accessibility updates
android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt, android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsHostA11yCoordinator.kt
The container consumes pending flags for item and bar updates. Accessibility properties are applied per item, and the container re-invalidates accessibility when the item view is unavailable.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant TabsContainer
  participant TabsMenuItemState
  participant TabsAppearanceCoordinator
  participant TabsAppearanceApplicator
  participant TabsHostA11yCoordinator
  TabsContainer->>TabsMenuItemState: consume pending flags
  TabsContainer->>TabsAppearanceCoordinator: apply flagged menu-item updates
  TabsAppearanceCoordinator->>TabsAppearanceApplicator: apply title, icon, or active-indicator size
  TabsContainer->>TabsHostA11yCoordinator: apply accessibility properties
  TabsHostA11yCoordinator-->>TabsContainer: report whether the item view was found
  TabsContainer->>TabsMenuItemState: re-invalidate accessibility if the view is unavailable
Loading

Suggested reviewers: stachbial, t0maboro, kmichalikk

Merge Risk: ⚪ Minimal · up to a5d48

No actionable regression was identified in the selective tab-item updates; this change is mergeable after normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 35.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 31 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description check Passed The description clearly explains the Android Tabs invalidation refactor, its behavior, implementation changes, and validation results.
Title check Passed The title clearly and concisely identifies the main change: applying Android tab title, badge, and accessibility updates only when invalidated.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from bc4e472 to efb203d Compare October 19, 2026 12:49
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from efb203d to afa6e07 Compare October 19, 2026 13:06
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from afa6e07 to d84266a Compare October 19, 2026 13:19
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from d84266a to 7f97149 Compare October 19, 2026 14:20
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from 7f97149 to aa774ad Compare October 20, 2026 11:26
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch 3 times, most recently from eba3f82 to aa44f2b Compare October 20, 2026 13:01
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from aa44f2b to 8e1385a Compare October 20, 2026 19:25
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch 2 times, most recently from 6d5c987 to fb9dde8 Compare October 21, 2026 09:39
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from fb9dde8 to 0bdd0c3 Compare October 21, 2026 10:01
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from 0bdd0c3 to 7c01082 Compare October 21, 2026 10:27
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from 7c01082 to 3f9f5bd Compare October 21, 2026 10:36
Base automatically changed from @kkafar/rfc-1823-android-tabs-move to main October 21, 2026 17:20
…y when invalidated

The `TITLE`, `BADGE` and `ACCESSIBILITY` flags only routed a change to
the per-item menu update, which then re-applied all three. Now, mirroring
the Stack header's `needsRebuild || flag`:

- the title is applied only for `TITLE` (no more manual diffing),
- the badge only for `BADGE`, except in the whole-bar appearance update,
  which re-applies every badge because badge colours come from the
  selected tab's appearance,
- accessibility only for `ACCESSIBILITY` in the per-item update; the
  whole-bar update still re-applies it to every item.

`TabsAppearanceApplicator.updateMenuItemAppearance` returns the flags it
consumed so the coordinator and the container can gate their parts.
A fresh menu item state is fully dirty, so new and rebuilt menu items get
everything applied.

Closes software-mansion/react-native-screens-labs#1956.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-gate-item-flags branch from 3f9f5bd to c210c80 Compare October 21, 2026 17:20
Comment on lines +256 to +293
@@ -283,6 +289,8 @@ internal class TabsAppearanceApplicator(
// The menu item's view doesn't exist yet; retry on the next update.
state.pendingFlags = state.pendingFlags or TabsItemInvalidationFlags.ACTIVE_INDICATOR_SIZE
}

return flags

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.

This method now does something peculiar. It zeros out the state.pendingFlags - so it takes responsibility for all the updates, but then it returns flags - so a set of still invalidated flags, that need to be applied somewhere else.

Suggest architecture here that actually does make sense.

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.

Agreed, the ownership was wrong: one function cleared every flag, applied three parts, and relied on two other classes to finish the job from its return value. e3c6bde restructures it as one consumer, apply-only primitives:

  • TabsMenuItemState.consumePendingFlags() reads and clears the flags. Its only caller is TabsContainer.applyPendingMenuItemChanges, which hands the flags to TabsAppearanceCoordinator.updateMenuItemAppearance (title, icon, active indicator, badge) and applies accessibility through TabsHostA11yCoordinator. Whoever consumes the flags applies everything they name.
  • The applicator only applies (applyTitle, applyIcon, applyActiveIndicatorSize, updateBadgeAppearance) and knows nothing about flags. A part that can't be applied yet (no item view) is re-invalidated through TabsBottomNavigationView.invalidateMenuItem, like any other change: the active indicator as before, and accessibility too since ce494b0 (it used to be dropped until the next a11y change or bar pass).
  • No isWholeBarUpdate: the bar pass (TabsContainer.updateTabBarAppearance: bar-wide appearance, then the menu items, then fonts) invalidates what it re-applies to every item and runs the same per-item path: BADGE, because badge colours come from the selected tab, and ACCESSIBILITY on appearance updates, which replaces setA11yPropertiesToAllTabItems and its index-based pairing. A fresh menu item state is still fully dirty, so rebuilt items get everything.

Side effect worth knowing: the colour-scheme path (applyDayNightUiMode) used to clear pending ACCESSIBILITY without applying it (safe only by call order). It now applies whatever it consumes. TabsHostA11yCoordinator loses its unused tabsScreenFragments constructor parameter.

#4976 and #4982 are rebased; everything compiles and passes yarn lint-android. Re-running the Android tabs Detox suites and a manual title / badge / a11y check on this PR alone (without #4976) now; I'll update the test plan with the results.


Generated by Claude Code

kkafar and others added 2 commits October 21, 2026 19:39
…s pending flags

Review follow-up: `TabsAppearanceApplicator.updateMenuItemAppearance`
cleared all of a menu item's pending flags, applied only some of them
(title, icon, active indicator) and returned them so the coordinator and
the container could apply the rest (badge, accessibility). Who owned the
flags depended on which caller looked at the return value.

Now there is one consumer and the parts are applied by their owners:

- `TabsContainer.applyPendingMenuItemChanges` is the only caller of
  `TabsMenuItemState.consumePendingFlags`. It hands the flags to
  `TabsAppearanceCoordinator.updateMenuItemAppearance` (title, icon,
  active indicator, badge) and applies accessibility through
  `TabsHostA11yCoordinator`.
- The applicator only applies (`applyTitle`, `applyIcon`,
  `applyActiveIndicatorSize`, `updateBadgeAppearance`) and knows nothing
  about flags. An active indicator that can't be applied yet is
  re-invalidated through `TabsBottomNavigationView.invalidateMenuItem`,
  like any other change.
- The whole-bar update no longer needs `isWholeBarUpdate`: it invalidates
  what it re-applies to every menu item (`BADGE`, as badge colours come
  from the selected tab; `ACCESSIBILITY` on appearance changes, replacing
  `setA11yPropertiesToAllTabItems`), then runs the same per-item path.
  `TabsContainer.updateTabBarAppearance` orders it: bar-wide appearance,
  menu items, fonts.

No behavioural change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s missing

Review follow-up. `setA11yPropertiesToTabItem` sets the test ID only when
the menu item's view exists, but its `ACCESSIBILITY` flag was consumed
either way, so a missing view dropped the update until the next
accessibility change or bar pass (before the flag gating, any per-item
update retried it). It now reports whether it found the view, and the
container re-invalidates `ACCESSIBILITY` when it didn't, like the active
indicator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment on lines +75 to +251
@@ -246,43 +248,32 @@ internal class TabsAppearanceApplicator(
}
}

fun updateMenuItemAppearance(
fun applyTitle(

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.

Should these methods be public? It seems that internal is enough here. They are used only from inside the core, right?

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.

fb1e94a: yes, only core's tabs code calls them. They are now explicitly internal: applyIconBox, applyActiveIndicatorSize, updateSharedAppearance, updateFontStyles, applyTitle and applyIcon in TabsAppearanceApplicator, plus the same for updateBarAppearance and updateFontStyles in TabsAppearanceCoordinator. Both classes were already internal, so the effective visibility doesn't change, but the declarations now say it.


Generated by Claude Code

Comment on lines +85 to +89
if (flags.containsAny(TabsItemInvalidationFlags.ICON_SIZE or TabsItemInvalidationFlags.ACTIVE_INDICATOR_SIZE) &&
!appearanceApplicator.applyActiveIndicatorSize(menuItem, item, bottomNavigationView.menuItemState(menuItem.itemId))
) {
// The menu item's view doesn't exist yet; retry on the next update.
bottomNavigationView.invalidateMenuItem(menuItem.itemId, TabsItemInvalidationFlags.ACTIVE_INDICATOR_SIZE)

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.

Nest an if block here with the second part of the condition. It'll be easier to read IMO.

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.

fb1e94a: nested. The same "apply, and re-invalidate if the item view is missing" check for accessibility in TabsContainer.applyPendingMenuItemChanges got the same shape.


Generated by Claude Code

Comment on lines +791 to +795
val menuItemId = fragment.menuItemId
val menuItem =
checkNotNull(bottomNavigationView.menu.findItem(menuItemId)) {
"[RNScreens] Missing MenuItem for id: $menuItemId"
}

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.

Check whether there isn't a helper method already for this. If yes - use it. If not - create one. Look for other places that might use the helper method.

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.

fb1e94a: there was none for a fragment → menu item lookup; MenuHelpers.kt only had getOrCreateMenuItemForFragment. Added next to it:

  • Menu.findMenuItemForFragment(fragment): MenuItem?
  • Menu.requireMenuItemForFragment(fragment): MenuItem (the checkNotNull with the "Missing MenuItem" message)

Used here, in TabsContainer.getMenuItemForItem and in getOrCreateMenuItemForFragment. Those were all the findItem(fragment.menuItemId) call sites in tabs; the only other menu.findItem calls in core are the Stack header toolbar's, which look items up by their own id map.


Generated by Claude Code

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.

1f0d8bd: done your way. TabsBottomNavigationView now has

internal fun findMenuItem(menuItemId: Int): MenuItem? = menu.findItem(menuItemId)

internal fun requireMenuItem(menuItemId: Int): MenuItem =
    checkNotNull(findMenuItem(menuItemId)) { "[RNScreens] Missing MenuItem for id: $menuItemId" }

and the call sites are bottomNavigationView.requireMenuItem(fragment.menuItemId) (here) and bottomNavigationView.findMenuItem(fragment.menuItemId) (getMenuItemForItem). The Menu extensions from fb1e94a are gone, so MenuHelpers.kt is back to its state on main.


Generated by Claude Code

kkafar and others added 3 commits October 21, 2026 20:27
…item lookup

Review follow-ups:

- `TabsAppearanceApplicator` and `TabsAppearanceCoordinator` methods are
  explicitly `internal`: only core's tabs code calls them.
- The "apply, and re-invalidate if the item view is missing" checks for
  the active indicator and accessibility are nested ifs.
- New `Menu.findMenuItemForFragment` / `Menu.requireMenuItemForFragment`
  helpers in `MenuHelpers.kt`, used by the whole-bar update,
  `getMenuItemForItem` and `getOrCreateMenuItemForFragment` instead of
  repeating `findItem(fragment.menuItemId)`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-up: instead of `Menu` extensions taking a fragment,
`TabsBottomNavigationView` has `findMenuItem(menuItemId)` and
`requireMenuItem(menuItemId)`, so call sites pass `fragment.menuItemId`
directly. `MenuHelpers.kt` is back to its state on `main`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-up (adversarial review): `onItemInvalidated` still said the
applicator re-applies the invalidated parts, `consumePendingFlags`
promised every flag is applied although `APPEARANCE` is bar-wide, and
"retry on the next update" suggested a scheduled retry where the flag is
only kept pending. Comments only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review decision: only core's `TabsContainer` uses it, and this PR changes
its signature anyway (no `tabsScreenFragments` parameter, a Boolean from
`setA11yPropertiesToTabItem`), so it should not be part of the public
surface.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kkafar
kkafar marked this pull request as ready for review October 22, 2026 08:53
@kkafar
kkafar merged commit 95bb9d2 into main Oct 22, 2026
5 of 6 checks passed
@kkafar
kkafar deleted the @kkafar/rfc-1823-android-tabs-gate-item-flags branch October 22, 2026 09:23
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.

1 participant