From e4474dbbc347c071eaaa5ef1d2578681affd53d9 Mon Sep 17 00:00:00 2001 From: Kacper Kafara Date: Mon, 19 Oct 2026 12:53:12 +0200 Subject: [PATCH 1/3] feat(Android, Tabs): apply tab configuration changes at the end of the 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 https://github.com/software-mansion/react-native-screens-labs/issues/1957. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tabs/container/TabsContainer.kt | 66 ++++++++++++++++--- .../react/containers/tabs/host/TabsHost.kt | 6 +- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt b/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt index 29690ffba4..ba67966431 100644 --- a/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt @@ -408,6 +408,38 @@ class TabsContainer internal constructor( // endregion + // region Update batch + + private var isUpdateBatchPending = false + + /** + * Announces that the host is applying a batch of updates (e.g. a React transaction). Tab + * configuration changes raised until [endUpdateBatch] are only recorded and applied together + * when the batch ends. + */ + internal fun beginUpdateBatch() { + isUpdateBatchPending = true + } + + internal fun endUpdateBatch() { + isUpdateBatchPending = false + flushPendingUpdates() + } + + /** + * Changes raised outside a batch (e.g. an icon loaded asynchronously) have no batch end to flush + * them, so they schedule a flush of their own. + */ + private fun scheduleFlushOutsideUpdateBatch() { + if (!isUpdateBatchPending) { + post { + flushPendingUpdates() + } + } + } + + // endregion + // region Tab configuration changes /** @@ -427,21 +459,19 @@ class TabsContainer internal constructor( // Skip when detached; reattachment reapplies the full appearance anyway. if (isAttachedToWindow) { invalidationFlags.isNavigationMenuAppearanceInvalidated = true - post { - this.flushPendingUpdates() - } } } else if (flags.containsAny(TabsItemInvalidationFlags.APPEARANCE)) { if (isAttachedToWindow && selectedTab.item === item) { invalidationFlags.isNavigationMenuAppearanceInvalidated = true - post { - this.flushPendingUpdates() - } } } if (flags.containsAny(TabsItemInvalidationFlags.MENU_ITEM_ATTRIBUTES)) { - updateMenuItem(item) + invalidationFlags.isMenuItemsInvalidated = true + } + + if (invalidationFlags.any()) { + scheduleFlushOutsideUpdateBatch() } } @@ -516,6 +546,20 @@ class TabsContainer internal constructor( private fun performPostSelectedTabUpdateActions() { updateBottomNavigationViewAppearanceIfNeeded() + updateMenuItemsIfNeeded() + } + + /** + * Applies the menu items' pending per-item changes. Runs after the whole-bar appearance update, + * which already consumed the pending flags of every menu item it updated. + */ + private fun updateMenuItemsIfNeeded() { + // Nothing is selected yet; the appearance update that follows the first selection applies them. + if (!invalidationFlags.isMenuItemsInvalidated || navState.isEmpty()) { + return + } + invalidationFlags.isMenuItemsInvalidated = false + tabsModel.forEach { updateMenuItem(it.item) } } private fun updateNavigationMenuStructureIfNeeded() { @@ -883,13 +927,19 @@ internal class TabsContainerInvalidationFlags( var isSelectedTabInvalidated: Boolean = false, var isNavigationMenuAppearanceInvalidated: Boolean = false, var isNavigationMenuStructureInvalidated: Boolean = false, + var isMenuItemsInvalidated: Boolean = false, ) { - internal fun any(): Boolean = isSelectedTabInvalidated || isNavigationMenuAppearanceInvalidated || isNavigationMenuStructureInvalidated + internal fun any(): Boolean = + isSelectedTabInvalidated || + isNavigationMenuAppearanceInvalidated || + isNavigationMenuStructureInvalidated || + isMenuItemsInvalidated internal fun invalidateAll() { isSelectedTabInvalidated = true isNavigationMenuAppearanceInvalidated = true isNavigationMenuStructureInvalidated = true + isMenuItemsInvalidated = true } internal fun invalidateOnSelectedTabChanged() { diff --git a/android/src/main/java/com/swmansion/rnscreens/react/containers/tabs/host/TabsHost.kt b/android/src/main/java/com/swmansion/rnscreens/react/containers/tabs/host/TabsHost.kt index 99c9d8d158..ce6d504f8f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/react/containers/tabs/host/TabsHost.kt +++ b/android/src/main/java/com/swmansion/rnscreens/react/containers/tabs/host/TabsHost.kt @@ -241,7 +241,7 @@ class TabsHost( } override fun didMountItems(uiManager: UIManager) { - container.flushPendingUpdates() + container.endUpdateBatch() } /** @@ -258,7 +258,9 @@ class TabsHost( override fun willDispatchViewUpdates(uiManager: UIManager) = Unit - override fun willMountItems(uiManager: UIManager) = Unit + override fun willMountItems(uiManager: UIManager) { + container.beginUpdateBatch() + } override fun didDispatchMountItems(uiManager: UIManager) = Unit From c40f4b2e697ab622ce8bb35bedf59e30bef2bc7d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:11:12 +0000 Subject: [PATCH 2/3] refactor(Android, Tabs): schedule container updates with ContainerUpdateScheduler 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) Claude-Session: https://claude.ai/code/session_01Xx1CNDu9HyxkbE3nRiXrfb --- .../common/ContainerUpdateScheduler.kt | 72 ++++++++++++++++++ .../tabs/container/TabsContainer.kt | 75 +++++++++---------- 2 files changed, 109 insertions(+), 38 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/core/containers/common/ContainerUpdateScheduler.kt diff --git a/android/src/main/java/com/swmansion/rnscreens/core/containers/common/ContainerUpdateScheduler.kt b/android/src/main/java/com/swmansion/rnscreens/core/containers/common/ContainerUpdateScheduler.kt new file mode 100644 index 0000000000..126eb29581 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/core/containers/common/ContainerUpdateScheduler.kt @@ -0,0 +1,72 @@ +package com.swmansion.rnscreens.core.containers.common + +import android.os.Handler +import android.os.Looper + +/** + * Decides when a container runs its update, so that any number of update requests results in + * a single run of [updateCallback]. + * + * - [requestUpdate] posts one run to the main thread's message queue. Further requests do nothing + * until that run happens. + * - Between [beginBatch] and [endBatch] (e.g. a React transaction) requests are only recorded, + * and [endBatch] runs the update once if any was requested. Batches don't nest. + * - [flush] runs the update right away, inside a batch too, and drops the posted run. + * + * The scheduler only decides when; [updateCallback] decides what, from the container's own + * invalidation state, and must do nothing when nothing is invalidated: [flush] runs it whether or + * not an update was requested. A request raised while [updateCallback] runs schedules another run. + * + * Main thread only. + */ +internal class ContainerUpdateScheduler( + private val updateCallback: () -> Unit, +) { + private val handler = Handler(Looper.getMainLooper()) + + // A run posted before a batch began waits for the batch to end. + private val postedUpdate = + Runnable { + if (!isInBatch) { + flush() + } + } + + private var isUpdateRequested = false + + private var isInBatch = false + + fun requestUpdate() { + if (isUpdateRequested) { + return + } + isUpdateRequested = true + if (!isInBatch) { + handler.post(postedUpdate) + } + } + + fun flush() { + cancel() + updateCallback() + } + + /** + * Drops the requested update without running it. + */ + fun cancel() { + isUpdateRequested = false + handler.removeCallbacks(postedUpdate) + } + + fun beginBatch() { + isInBatch = true + } + + fun endBatch() { + isInBatch = false + if (isUpdateRequested) { + flush() + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt b/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt index ba67966431..f76dd37308 100644 --- a/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt @@ -29,6 +29,7 @@ import com.swmansion.rnscreens.core.common.safearea.SafeAreaProvider import com.swmansion.rnscreens.core.common.text.FontResolver import com.swmansion.rnscreens.core.containers.common.Container import com.swmansion.rnscreens.core.containers.common.ContainerItem +import com.swmansion.rnscreens.core.containers.common.ContainerUpdateScheduler import com.swmansion.rnscreens.core.containers.common.ParentContainerItemRegistry import com.swmansion.rnscreens.core.containers.tabs.appearance.TabsAppearance import com.swmansion.rnscreens.core.containers.tabs.appearance.TabsAppearanceCoordinator @@ -172,9 +173,7 @@ class TabsContainer internal constructor( if (newValue != oldValue) { updateInterfaceInsets() invalidationFlags.isNavigationMenuAppearanceInvalidated = true - post { - flushPendingUpdates() - } + updateScheduler.requestUpdate() } } @@ -196,8 +195,8 @@ class TabsContainer internal constructor( get() = navState /** - * Queue a navigation state update. Apply via [flushPendingUpdates] or rely on the - * host's next render cycle to apply automatically. + * Queue a navigation state update. The container applies it with its next scheduled update; + * call [flushPendingUpdates] to apply it right away. */ fun submitSelectionOfTabsScreenWithKey(screenKey: String) { setPendingNavigationStateUpdate( @@ -210,14 +209,10 @@ class TabsContainer internal constructor( } /** - * Apply any pending invalidations and state updates in a single coordinated pass. - * No-op when nothing is dirty or the view is detached. + * Apply any pending invalidations and state updates in a single coordinated pass, right away + * instead of with the next scheduled update. No-op when nothing is dirty or the view is detached. */ - fun flushPendingUpdates() { - if (invalidationFlags.any() && isAttachedToWindow) { - performContainerUpdate() - } - } + fun flushPendingUpdates() = updateScheduler.flush() fun addNavigationStateObserver(observer: TabsNavigationStateObserver): Boolean = observerRegistry.add(observer) @@ -228,12 +223,15 @@ class TabsContainer internal constructor( // region Host-internal API /** - * Queue a navigation state update. Apply via [flushPendingUpdates] or rely on the - * host's next render cycle to apply automatically. + * Queue a navigation state update. The container applies it with its next scheduled update; + * call [flushPendingUpdates] to apply it right away. */ internal fun setPendingNavigationStateUpdate(request: TabsNavigationStateUpdateRequest?) { pendingStateUpdateRequest = request invalidationFlags.isSelectedTabInvalidated = request != null + if (request != null) { + updateScheduler.requestUpdate() + } } internal fun addItemAt( @@ -243,12 +241,14 @@ class TabsContainer internal constructor( tabsModel.add(index, TabsScreenFragment(item, viewIdProvider.generateViewId(), WeakReference(this))) item.setConfigurationObserver { flags -> onItemInvalidated(item, flags) } invalidationFlags.invalidateAll() + updateScheduler.requestUpdate() } internal fun removeItemAt(index: Int): TabsContainerItem? = tabsModel.removeAt(index).item.also { it.setConfigurationObserver(null) invalidationFlags.invalidateAll() + updateScheduler.requestUpdate() } internal fun removeItem(item: TabsContainerItem): Boolean = @@ -256,6 +256,7 @@ class TabsContainer internal constructor( if (isRemoved) { item.setConfigurationObserver(null) invalidationFlags.invalidateAll() + updateScheduler.requestUpdate() } } @@ -263,6 +264,7 @@ class TabsContainer internal constructor( tabsModel.forEach { it.item.setConfigurationObserver(null) } tabsModel.clear() invalidationFlags.invalidateAll() + updateScheduler.requestUpdate() } internal fun setupFragmentManager() { @@ -284,6 +286,7 @@ class TabsContainer internal constructor( internal fun tearDown() { observerRegistry.clear() setPendingNavigationStateUpdate(null) + updateScheduler.cancel() } internal fun onAfterSetSelectedItemId( @@ -408,35 +411,22 @@ class TabsContainer internal constructor( // endregion - // region Update batch - - private var isUpdateBatchPending = false + // region Update scheduling /** - * Announces that the host is applying a batch of updates (e.g. a React transaction). Tab - * configuration changes raised until [endUpdateBatch] are only recorded and applied together - * when the batch ends. + * Every invalidation requests an update; the scheduler coalesces the requests into a single + * [performContainerUpdateIfNeeded]. Outside a batch (e.g. an icon loaded asynchronously) the + * update is posted to the main thread's message queue. */ - internal fun beginUpdateBatch() { - isUpdateBatchPending = true - } - - internal fun endUpdateBatch() { - isUpdateBatchPending = false - flushPendingUpdates() - } + private val updateScheduler = ContainerUpdateScheduler(::performContainerUpdateIfNeeded) /** - * Changes raised outside a batch (e.g. an icon loaded asynchronously) have no batch end to flush - * them, so they schedule a flush of their own. + * Announces that the host is applying a batch of updates (e.g. a React transaction). Updates + * requested until [endUpdateBatch] are applied together, once, when the batch ends. */ - private fun scheduleFlushOutsideUpdateBatch() { - if (!isUpdateBatchPending) { - post { - flushPendingUpdates() - } - } - } + internal fun beginUpdateBatch() = updateScheduler.beginBatch() + + internal fun endUpdateBatch() = updateScheduler.endBatch() // endregion @@ -471,7 +461,7 @@ class TabsContainer internal constructor( } if (invalidationFlags.any()) { - scheduleFlushOutsideUpdateBatch() + updateScheduler.requestUpdate() } } @@ -517,6 +507,15 @@ class TabsContainer internal constructor( // region Private helpers + /** + * The update the [updateScheduler] runs. Skipped while detached: [onAttachedToWindow] flushes. + */ + private fun performContainerUpdateIfNeeded() { + if (invalidationFlags.any() && isAttachedToWindow) { + performContainerUpdate() + } + } + /** * This is where programmatic update flow starts. * This includes any JS-triggered action (navigation state update, prop update, etc.) From 4ff4e0f128859a8d731bb331d21817ba0814c191 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:12:08 +0000 Subject: [PATCH 3/3] fix(Android, Tabs): apply the selected tab's appearance after a user tap 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) Claude-Session: https://claude.ai/code/session_01Xx1CNDu9HyxkbE3nRiXrfb --- .../core/containers/tabs/container/TabsContainer.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt b/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt index f76dd37308..1b6d44a3bb 100644 --- a/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/core/containers/tabs/container/TabsContainer.kt @@ -760,6 +760,11 @@ class TabsContainer internal constructor( // This line assumes that any required e.g. appearance actions will be performed // synchronously later in the flow. invalidationFlags.invalidateOnSelectedTabChanged() + if (actionOrigin == TabsActionOrigin.USER) { + // A tap reaches this listener through the menu's item action, not `setSelectedItemId`, + // so [onAfterSetSelectedItemId] doesn't run for it: schedule the update instead. + updateScheduler.requestUpdate() + } } if (stateChanged) {