Skip to content

perf(Android, Tabs): apply tab configuration changes at the end of the React transaction - #4976

Draft
kkafar wants to merge 3 commits into
@kkafar/rfc-1823-android-tabs-gate-item-flagsfrom
@kkafar/rfc-1823-android-tabs-batch-item-updates
Draft

kkafar wants to merge 3 commits into
@kkafar/rfc-1823-android-tabs-gate-item-flagsfrom
@kkafar/rfc-1823-android-tabs-batch-item-updates

Conversation

@kkafar

@kkafar kkafar commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Requested by Kacper · project thread

Description

Follow-up to #4920, raised in its review. Tab configuration changes reached the tab bar as they arrived. A title, icon, badge or accessibility change updated that tab's menu item synchronously, inside the prop setter. Appearance changes posted a whole-bar update. React delivers props in batches, so one transaction could update the same menu item several times, and the posted bar update could run apart from the transaction that caused it.

Now the tab container batches them, like StackContainer does: changes raised during a React transaction are recorded and applied once when it ends.

When the container updates is decided by a new reusable core primitive, ContainerUpdateScheduler (core/containers/common). It takes a single update callback and coalesces any number of update requests into one run: outside a batch it posts one run to the main thread's message queue (no more one post { flushPendingUpdates() } per change), inside a batch it only records the request and runs once when the batch ends, and the update can be flushed at any moment. TabsContainer is its first user.

This is a timing change, not a structural refactor: menu item and appearance updates raised inside a transaction now land at the transaction's end, in the same frame, instead of in the prop setter or a posted runnable.

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

Stacked on #4975.

Changes

  • TabsHost.willMountItems / didMountItems now call TabsContainer.beginUpdateBatch / endUpdateBatch (previously didMountItems called flushPendingUpdates), mirroring StackHost / StackContainer.
  • Inside a batch, TabsContainer.onItemInvalidated only records: the menu item's pending flags (as before), plus the container's isNavigationMenuAppearanceInvalidated (size changes, or appearance changes of the selected tab, under the same attached / selected checks as before) or the new isMenuItemsInvalidated (title / icon / badge / accessibility).
  • performContainerUpdate applies them once at the end of the batch. The new updateMenuItemsIfNeeded runs after the whole-bar appearance update, which already consumed the flags of the items it updated, and is skipped until the first tab is selected.
  • ContainerUpdateScheduler (commit "schedule container updates with ContainerUpdateScheduler"):
    • requestUpdate() posts one run to the main thread's Handler; further requests do nothing until it runs.
    • beginBatch() / endBatch(): requests are only recorded, and endBatch runs the update once if any was requested. A run posted before the batch began waits for its end. Batches don't nest.
    • flush() runs the update right away, inside a batch too, and drops the posted run. cancel() drops it without running.
    • The scheduler decides when; the callback decides what, from the container's invalidation flags, and does nothing when nothing is invalidated.
  • TabsContainer adoption (same commit): every container invalidation now calls updateScheduler.requestUpdate(): the tabBarHidden setter (replacing its post { flushPendingUpdates() }), setPendingNavigationStateUpdate, adding and removing items, and tab configuration changes (replacing scheduleFlushOutsideUpdateBatch). begin/endUpdateBatch delegate to the scheduler, flushPendingUpdates() is updateScheduler.flush(), its old body is the scheduler's callback performContainerUpdateIfNeeded, and tearDown cancels a pending run. The batch end now runs the update only when something requested one, instead of on every React transaction in the app.
  • Outside a batch: changes with no transaction to end (asynchronous icon loads, configuration changes, submitSelectionOfTabsScreenWithKey) are applied by the scheduler's posted run, at most one per main loop iteration. A tabBarHidden change is applied at the end of the transaction that set it, instead of in a posted runnable.
  • User taps (commit "apply the selected tab's appearance after a user tap"): Material routes a tap through menu.performItemAction, not setSelectedItemId, so onAfterSetSelectedItemId(USER) never ran for it and the new tab's appearance waited for the next container update (usually the React transaction echoing the navigation state from JS). onMenuItemSelected now requests an update for user-originated selection changes.
  • Detached container: it keeps accumulating, and onAttachedToWindow flushes, as before.

Test plan

Run after every rebase of this branch, last on f4283d0:

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

On device (Android, FabricExample, release build, Pixel 3a API 34 emulator), on the batching commit before the scheduler commits (8a2bdcf, before the rebase):

  • 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.

To re-run with the scheduler commits:

  • The Detox suites above.
  • test-tabs-item-icon (async icons go through the posted run) and test-tabs-item-icon-tint-and-size.
  • Tab bar hidden toggle (tabBarHidden) applies and the content insets follow.
  • Tapping a tab with its own appearance applies it immediately, with the JS thread busy too.

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 with Claude Code

https://claude.ai/code/session_01Xx1CNDu9HyxkbE3nRiXrfb

@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

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • 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-batch-item-updates branch from 57467de to fbfec69 Compare October 19, 2026 12:49
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from fbfec69 to dac50d7 Compare October 19, 2026 13:06
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from dac50d7 to d1bb5ad Compare October 19, 2026 13:19
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from d1bb5ad to 6fc9c98 Compare October 19, 2026 14:20
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from 6fc9c98 to bbc8c4a Compare October 20, 2026 11:26
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch 3 times, most recently from 49e4828 to 19cc028 Compare October 20, 2026 13:01
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from 19cc028 to ea94447 Compare October 20, 2026 19:25
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch 2 times, most recently from 18c9a16 to a38412a Compare October 21, 2026 09:39
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from a38412a to 2fa7395 Compare October 21, 2026 10:01
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from 2fa7395 to 78158a9 Compare October 21, 2026 10:27
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from 78158a9 to f456f40 Compare October 21, 2026 10:36
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from f456f40 to 2a44942 Compare October 21, 2026 17:20
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from 2a44942 to 8a2bdcf Compare October 21, 2026 17:43
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch 3 times, most recently from 0fa6b66 to 9d8597d Compare October 21, 2026 18:38
@kkafar kkafar closed this Oct 21, 2026
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from 9d8597d to a5d4810 Compare October 21, 2026 18:46
kkafar and others added 3 commits October 21, 2026 20:46
…e React transaction

Tab configuration changes used to reach the tab bar as they arrived: a
title, icon, badge or accessibility change updated that tab's menu item
synchronously inside the prop setter, and appearance changes posted a
whole-bar update. React delivers props in batches, so one transaction
could update the same menu item several times, and the posted bar update
could run apart from the transaction that caused it.

Mirror `StackContainer.beginUpdateBatch` / `endUpdateBatch`:
`TabsHost.willMountItems` / `didMountItems` now bracket the transaction
with `TabsContainer.beginUpdateBatch` / `endUpdateBatch`. Inside a batch,
tab invalidations only record their flags (per menu item, plus
`isMenuItemsInvalidated` / `isNavigationMenuAppearanceInvalidated` on the
container) and are applied once by the flush at the end of the batch
(`updateMenuItemsIfNeeded` after the whole-bar update). Changes raised
outside a batch (asynchronous icon loads, configuration changes) post a
flush of their own. A detached container keeps accumulating and flushes
when attached, as before.

This moves menu item updates to the end of the transaction, so it is a
timing change, not a structural one.

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

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

TabsContainer posted `flushPendingUpdates` for every change raised outside
a React transaction (`tabBarHidden`, `scheduleFlushOutsideUpdateBatch`), so
several changes in one main loop iteration sent several messages, each
running its own flush. Inside a transaction it held a boolean and flushed
at its end whether or not anything had changed.

Add `ContainerUpdateScheduler` to core's containers/common: it takes a
single update callback and coalesces requests into one run of it.
`requestUpdate` posts one run to the main thread's message queue and does
nothing while that run is pending; between `beginBatch` and `endBatch`
requests are only recorded and `endBatch` runs the update once if any
was requested; `flush` runs it right away (inside a batch too) and drops
the posted run; `cancel` drops it without running.

TabsContainer now requests an update after each invalidation (tab bar
visibility, navigation state request, adding and removing items, tab
configuration changes), `begin/endUpdateBatch` delegate to the scheduler,
`flushPendingUpdates` flushes it, and `tearDown` cancels a pending run.
The `tabBarHidden` change is applied at the end of the transaction that
set it instead of in a posted runnable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xx1CNDu9HyxkbE3nRiXrfb
A tap reaches `TabsContainer.onMenuItemSelected` through Material's
`menu.performItemAction` (the menu view's click listener), not through
`setSelectedItemId`, so `onAfterSetSelectedItemId(USER)` never runs for
it. The appearance invalidated by the selection change was only applied
by the next container update, usually the React transaction that echoes
the new navigation state from JS.

Request an update for user-originated selection changes, so the
scheduler applies the new tab's appearance on the next main loop
iteration. Programmatic changes keep applying it later in the same
`performContainerUpdate` pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xx1CNDu9HyxkbE3nRiXrfb
@kkafar kkafar reopened this Oct 21, 2026

This branch has not been deployed

No deployments
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.

2 participants