From 719b2d7d7b40ecc81782e6c12e82cb93ec29f252 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 1 Jul 2026 12:42:25 +0200 Subject: [PATCH 1/5] change view command to accept multiple updates on JS layer --- .../header/StackHeaderConfig.android.tsx | 20 ++++--- .../header/StackHeaderConfig.android.types.ts | 52 ++++++++++++++----- src/components/gamma/stack/index.ts | 1 + ...StackHeaderConfigAndroidNativeComponent.ts | 12 ++--- 4 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx index f16efcebdf..851b02be1f 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx @@ -35,6 +35,7 @@ import type { StackHeaderToolbarMenuItemBaseAndroid, StackHeaderTypeAndroid, StackHeaderToolbarMenuElementOptionsAndroid, + StackHeaderToolbarMenuElementUpdateAndroid, StackHeaderToolbarMenuGroupAndroid, } from './StackHeaderConfig.android.types'; import { parseAndroidIconToNativeProps } from '../../../shared'; @@ -232,7 +233,7 @@ function useHeaderConfigRef(forwardedRef: Ref) { useImperativeHandle(forwardedRef, () => ({ android: { - setToolbarMenuElementOptions: (id, options) => { + updateToolbarMenuElements: updates => { if (!ref.current) { console.warn( '[RNScreens] Reference to native header config component has not been updated yet.', @@ -240,10 +241,17 @@ function useHeaderConfigRef(forwardedRef: Ref) { return; } - StackHeaderConfigAndroidNativeCommands.setToolbarMenuElementOptions( - ref.current, + const updatesArray: StackHeaderToolbarMenuElementUpdateAndroid[] = + Array.isArray(updates) ? updates : [updates]; + + const nativeUpdates = updatesArray.map(({ id, options }) => ({ id, - parseToolbarMenuElementOptionsToNativeProps(options), + ...parseToolbarMenuElementOptionsToNativeProps(options), + })); + + StackHeaderConfigAndroidNativeCommands.updateToolbarMenuElements( + ref.current, + nativeUpdates, ); }, }, @@ -516,7 +524,7 @@ function parseBaseItemToNativeProps({ function parseToolbarMenuElementOptionsToNativeProps( options: StackHeaderToolbarMenuElementOptionsAndroid, -): NativeToolbarMenuElementOptionsAndroid[] { +): NativeToolbarMenuElementOptionsAndroid { const nativeOptions: NativeToolbarMenuElementOptionsAndroid = Object.fromEntries( Object.entries(options).flatMap(([key, value]): [string, unknown][] => { @@ -579,7 +587,7 @@ function parseToolbarMenuElementOptionsToNativeProps( ); // For some reason Codegen requires passing an array (we can't use plain object). - return [nativeOptions]; + return nativeOptions; } export default forwardRef( diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts index 2fad1967a4..3106cc072a 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts @@ -83,7 +83,7 @@ export interface StackHeaderToolbarMenuItemBaseAndroid { * * @remarks * If `title` is changed for the element of type `menu` by using the - * `setToolbarMenuElementOptions` view command, the menu title (`menuTitle`) + * `updateToolbarMenuElements` view command, the menu title (`menuTitle`) * will also be changed to the new title (unless the new title is set to * `undefined`). In order to keep the custom menu title, you should also * include `menuTitle` in the view command. @@ -375,7 +375,7 @@ export interface StackHeaderToolbarMenuAndroid * `title`, which controls the label shown in the parent menu's item row. * * @remarks - * If `title` is changed by using the `setToolbarMenuElementOptions` view + * If `title` is changed by using the `updateToolbarMenuElements` view * command, the menu title will also be changed to the new title (unless the * new title is set to `undefined`). In order to keep the custom menu title, * you should also include `menuTitle` in the view command. @@ -414,19 +414,43 @@ export type StackHeaderToolbarMenuElementOptionsAndroid = Partial< menuTitle?: string | undefined; }; -export interface StackHeaderConfigCommandsAndroid { +export interface StackHeaderToolbarMenuElementUpdateAndroid { /** - * @summary Allows to change menu element configuration in runtime. + * @summary The ID of the menu element to update. * - * @param id The ID of the menu element which will be updated. - * @param options Object with properties that should be changed. If property - * is omitted, the current value will be preserved. If property is - * explicitly set to `undefined`, the default value of the prop will be - * restored. + * @platform android + */ + id: string; + /** + * @summary Options to apply to the menu element. + * + * @platform android */ - setToolbarMenuElementOptions: ( - id: string, - options: StackHeaderToolbarMenuElementOptionsAndroid, + options: StackHeaderToolbarMenuElementOptionsAndroid; +} + +export interface StackHeaderConfigCommandsAndroid { + /** + * @summary Applies multiple menu element updates in a single batch. + * + * @description + * Accepts a single update or an array of updates. Each update targets a menu + * element by `id` and applies the given `options`. + * + * Updates that do not include an icon change are applied synchronously in + * iteration order. If an update includes an icon change (`icon` field in + * `options`) that requires asynchronous image loading, all options for that + * element (not just the icon) wait for the image to load before being + * applied — there is no partial application. Such updates may be applied + * after non-icon updates and in an unpredictable order relative to each + * other. + * + * @param updates A single update object or an array of updates. + */ + updateToolbarMenuElements: ( + updates: + | StackHeaderToolbarMenuElementUpdateAndroid + | StackHeaderToolbarMenuElementUpdateAndroid[], ) => void; } @@ -615,11 +639,11 @@ export interface StackHeaderConfigPropsAndroid { * * @description * This prop serves as initial configuration of the toolbar menu. If you - * want to change some property in runtime, use `setToolbarMenuElementOptions` + * want to change some property in runtime, use `updateToolbarMenuElements` * view command. * * Changing this prop in runtime will result in full toolbar menu rebuild. - * Any prior changes applied via `setToolbarMenuElementOptions` will be lost. + * Any prior changes applied via `updateToolbarMenuElements` will be lost. * * @platform android */ diff --git a/src/components/gamma/stack/index.ts b/src/components/gamma/stack/index.ts index 9231abc56b..e153d08469 100644 --- a/src/components/gamma/stack/index.ts +++ b/src/components/gamma/stack/index.ts @@ -31,6 +31,7 @@ export type { StackHeaderToolbarMenuItemAndroid, StackHeaderToolbarMenuItemBaseAndroid, StackHeaderToolbarMenuElementOptionsAndroid, + StackHeaderToolbarMenuElementUpdateAndroid, StackHeaderToolbarMenuItemShowAsActionAndroid, StackHeaderToolbarMenuItemTypeAndroid, // iOS diff --git a/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts index 06b8e7fbed..5a4e62c6e6 100644 --- a/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts @@ -121,18 +121,18 @@ export type StackHeaderToolbarMenuElementOptionsAndroid = Partial< menuTitle?: string | undefined; }; +type StackHeaderToolbarMenuElementUpdateNativeAndroid = + StackHeaderToolbarMenuElementOptionsAndroid & { id: string }; + export interface NativeCommands { - setToolbarMenuElementOptions: ( + updateToolbarMenuElements: ( viewRef: React.ComponentRef, - id: string, - // We use the array here only due to codegen limitation. We're using only - // the first index of the array. - options: StackHeaderToolbarMenuElementOptionsAndroid[], + updates: StackHeaderToolbarMenuElementUpdateNativeAndroid[], ) => void; } export const Commands: NativeCommands = codegenNativeCommands({ - supportedCommands: ['setToolbarMenuElementOptions'], + supportedCommands: ['updateToolbarMenuElements'], }); export default codegenNativeComponent( From 39d94208d38d99a4cd524d51bdae2f171578a0b9 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 1 Jul 2026 12:43:35 +0200 Subject: [PATCH 2/5] update SFTs after changing view command --- .../test-stack-toolbar-menu-commands-android/index.tsx | 6 +++--- .../test-stack-toolbar-menu-commands-android/scenario.md | 2 +- .../test-stack-toolbar-menu-groups-android/index.tsx | 6 +++--- .../test-stack-toolbar-menu-groups-android/scenario.md | 4 ++-- .../stack-v5/test-stack-toolbar-menu-icon-android/index.tsx | 6 +++--- .../scenario-descriptions.ts | 2 +- .../test-stack-toolbar-menu-icon-android/scenario.md | 2 +- .../index.tsx | 6 +++--- .../scenario.md | 2 +- .../test-stack-toolbar-menu-title-android/index.tsx | 6 +++--- .../test-stack-toolbar-menu-title-android/scenario.md | 2 +- .../test-stack-toolbar-nested-menu-android/index.tsx | 6 +++--- .../test-stack-toolbar-nested-menu-android/scenario.md | 4 ++-- 13 files changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/index.tsx index 05ca2dac27..87fa544ee0 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/index.tsx @@ -156,10 +156,10 @@ function MainScreen() { ...(cmdTitle !== 'no change' && { title: resolveTitle(cmdTitle) }), ...(cmdHidden !== 'no change' && { hidden: resolveHidden(cmdHidden) }), }; - headerConfigRef.current?.android?.setToolbarMenuElementOptions( - cmdTargetId, + headerConfigRef.current?.android?.updateToolbarMenuElements({ + id: cmdTargetId, options, - ); + }); }, [cmdTargetId, cmdTitle, cmdHidden]); return ( diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/scenario.md index 28d2776813..af157ee5c1 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/scenario.md +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-commands-android/scenario.md @@ -4,7 +4,7 @@ **Description:** This test focuses on the Android toolbar menu items API on the gamma stack header — both the `toolbarMenu` prop and the -imperative `setToolbarMenuElementOptions(id, options)` command. It verifies +imperative `updateToolbarMenuElements(id, options)` command. It verifies that the menu renders correctly from props on first mount, that the per-item `onPress` callback fires with the correct id, and that imperative commands behave as specified: fields absent from `options` diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-groups-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-groups-android/index.tsx index cc9997958a..141914de84 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-groups-android/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-groups-android/index.tsx @@ -276,10 +276,10 @@ function MainScreen() { hidden: cmdHidden === 'undefined' ? undefined : cmdHidden === 'true', }), }; - headerConfigRef.current?.android?.setToolbarMenuElementOptions( - cmdTargetId, + headerConfigRef.current?.android?.updateToolbarMenuElements({ + id: cmdTargetId, options, - ); + }); }, [cmdTargetId, cmdChecked, cmdTitle, cmdHidden]); return ( diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-groups-android/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-groups-android/scenario.md index 4ef4876a24..43d94c40d6 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-groups-android/scenario.md +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-groups-android/scenario.md @@ -8,7 +8,7 @@ group behavior, `onSelectionChange` callbacks with correct IDs on groups of items, `onPress` on action items, `initialToggleState`, `toolbarMenuGroupDividerEnabled`, runtime props updates (group type change, adding/removing items, full rebuild resetting command state), and imperative -`setToolbarMenuElementOptions` commands for setting `checked`, `title`, `hidden`, +`updateToolbarMenuElements` commands for setting `checked`, `title`, `hidden`, and hidden items preserving their selection in callbacks. **OS test creation version:** Android: API Level 36 @@ -25,7 +25,7 @@ TBD — automation is possible and planned but not yet implemented. - Groups are scoped to the menu level they are defined in — groups cannot span submenus. -- `setToolbarMenuElementOptions` targets elements by `id`. It works +- `updateToolbarMenuElements` targets elements by `id`. It works on items at any nesting depth. ## Steps diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx index 7c712f05f3..7065d8875f 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx @@ -246,10 +246,10 @@ function MainScreen() { disabled: cmdDisabled === 'true', }), }; - headerConfigRef.current?.android?.setToolbarMenuElementOptions( - cmdTargetId, + headerConfigRef.current?.android?.updateToolbarMenuElements({ + id: cmdTargetId, options, - ); + }); }, [ cmdTargetId, cmdIcon, diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario-descriptions.ts b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario-descriptions.ts index 0fed593474..3564bc7e22 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario-descriptions.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario-descriptions.ts @@ -4,7 +4,7 @@ export const scenarioDescription: ScenarioDescription = { name: 'Stack Toolbar Menu Icon', key: 'test-stack-toolbar-menu-icon-android', details: - 'Tests the icon and state-aware tint props on toolbar menu items, both via props and via the setToolbarMenuElementOptions view command.', + 'Tests the icon and state-aware tint props on toolbar menu items, both via props and via the updateToolbarMenuElements view command.', platforms: ['android'], e2eCoverage: 'tbd', smokeTest: false, diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario.md index b06f3dfee7..0e850e72fd 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario.md +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario.md @@ -4,7 +4,7 @@ **Description:** Tests the `icon` and state-aware `iconTintColor*` props on Android toolbar menu items. Covers both the props flow (via `toolbarMenu` -prop) and the imperative command flow (via `setToolbarMenuElementOptions`). The +prop) and the imperative command flow (via `updateToolbarMenuElements`). The props flow rebuilds all items from scratch on every update and discards all prior command state on all items simultaneously. The command flow merges changes onto individual items: absent fields preserve the current value; fields set to diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-show-as-action-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-show-as-action-android/index.tsx index a0bcc177ae..0ff97bcdd6 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-show-as-action-android/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-show-as-action-android/index.tsx @@ -183,10 +183,10 @@ function MainScreen() { showAsAction: resolveShowAsAction(cmdShowAsAction), }), }; - headerConfigRef.current?.android?.setToolbarMenuElementOptions( - cmdTargetId, + headerConfigRef.current?.android?.updateToolbarMenuElements({ + id: cmdTargetId, options, - ); + }); }, [cmdTargetId, cmdIcon, cmdShowAsAction]); return ( diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-show-as-action-android/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-show-as-action-android/scenario.md index 3b5942f57f..701830b930 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-show-as-action-android/scenario.md +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-show-as-action-android/scenario.md @@ -7,7 +7,7 @@ toolbar menu items. It verifies that items placed in the action bar vs overflow menu behave correctly for all five values: `never`, `always`, `alwaysWithText`, `ifRoom`, `ifRoomWithText`. It also verifies that the default (omitted prop) is equivalent to `never`, -and that runtime updates via `setToolbarMenuElementOptions` — including +and that runtime updates via `updateToolbarMenuElements` — including resetting to the default with `undefined` — take effect immediately. The test exercises the interaction between `showAsAction` and the `icon` prop, since the `WITH_TEXT` modifier only has a visible diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-title-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-title-android/index.tsx index 022b719709..6562888699 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-title-android/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-title-android/index.tsx @@ -228,10 +228,10 @@ function MainScreen() { tooltipText: resolveOptionalString(cmdTooltip), }), }; - headerConfigRef.current?.android?.setToolbarMenuElementOptions( - cmdTargetId, + headerConfigRef.current?.android?.updateToolbarMenuElements({ + id: cmdTargetId, options, - ); + }); }, [cmdTargetId, cmdTitle, cmdCondensed, cmdTooltip]); return ( diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-title-android/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-title-android/scenario.md index 16ad61c801..f17801ea9a 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-title-android/scenario.md +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-title-android/scenario.md @@ -36,7 +36,7 @@ TBD — automation is possible and planned but not yet implemented. hidden in **portrait**. An item with text but **no icon** always shows its text. - Changing any **Props** control rebuilds the whole menu and discards any - prior `setToolbarMenuElementOptions` state. + prior `updateToolbarMenuElements` state. ## Steps diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-nested-menu-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-nested-menu-android/index.tsx index cb24b78869..6e0466d5ca 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-nested-menu-android/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-nested-menu-android/index.tsx @@ -250,10 +250,10 @@ function MainScreen() { menuTitle: resolveMenuTitle(cmdMenuTitle), }), }; - headerConfigRef.current?.android?.setToolbarMenuElementOptions( - cmdTargetId, + headerConfigRef.current?.android?.updateToolbarMenuElements({ + id: cmdTargetId, options, - ); + }); }, [cmdTargetId, cmdTitle, cmdHidden, cmdMenuTitle]); return ( diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-nested-menu-android/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-nested-menu-android/scenario.md index 5a4d36665d..a364a57fd6 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-nested-menu-android/scenario.md +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-nested-menu-android/scenario.md @@ -6,7 +6,7 @@ support on the gamma stack header. It verifies that submenus render correctly as expandable groups in the overflow menu, that `onPress` fires with the correct id for items at every nesting level (including -deeply nested submenus), that imperative `setToolbarMenuElementOptions` +deeply nested submenus), that imperative `updateToolbarMenuElements` commands work on both leaf items inside submenus and on submenu containers themselves (including `menuTitle` changes), and that any props update rebuilds the entire menu tree — discarding all prior @@ -27,7 +27,7 @@ TBD — automation is possible and planned but not yet implemented. - Submenus appear as items with a submenu indicator (caret/arrow icon). Tapping a submenu opens a nested menu instead of firing `onPress`. -- `setToolbarMenuElementOptions` targets elements by `id`. It works +- `updateToolbarMenuElements` targets elements by `id`. It works on both leaf items and submenu containers at any nesting depth. - `menuTitle` controls the header text shown above submenu items when the submenu is opened. Only `submenu-1` has `menuTitle` From fdd33a79773adb127c6ae4cd481b2d0b45f24411 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 1 Jul 2026 14:08:16 +0200 Subject: [PATCH 3/5] native side --- .../stack/header/config/StackHeaderConfig.kt | 17 ++++++++++-- .../config/StackHeaderConfigViewManager.kt | 26 ++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt index 180d9f2ca4..126f7c9790 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt @@ -193,7 +193,7 @@ internal class StackHeaderConfig( // Last resolved icon per menu item id. Unlike every other field on this config — which // mirrors a single prop — this cache deliberately merges resolved icons from BOTH sources // that can set a menu item icon: the `toolbarMenu` prop - // (resolveToolbarMenuItemIconsIfNeeded) and the imperative `setToolbarMenuElementOptions` + // (resolveToolbarMenuItemIconsIfNeeded) and the imperative `updateToolbarMenuElements` // view command (dispatchMenuElementUpdate). It is necessary to ensure consistency. private var toolbarMenuItemIcons = mapOf() @@ -368,13 +368,26 @@ internal class StackHeaderConfig( // region Imperative menu item commands + /** + * Applies a batch of toolbar menu item view commands. Each update follows the same semantics + * as [dispatchMenuElementUpdate]: updates without icon changes are delivered immediately, + * updates with icon changes wait for (possibly async) image resolution. + */ + internal fun dispatchMenuElementUpdates( + updates: List>, + ) { + for ((id, options, iconSource) in updates) { + dispatchMenuElementUpdate(id, options, iconSource) + } + } + /** * Applies a toolbar menu item view command. When the command does not touch the icon * ([iconSource] is `null`) the options are delivered immediately. Otherwise, the icon is * resolved first and all options — including the icon — are delivered together in a single * update, so the change is applied atomically once the (possibly async) image has loaded. */ - internal fun dispatchMenuElementUpdate( + private fun dispatchMenuElementUpdate( id: String, options: StackHeaderToolbarMenuElementOptions, iconSource: StackHeaderToolbarMenuItemIconSource?, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt index d4147e99c6..34c752f019 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt @@ -232,17 +232,25 @@ internal open class StackHeaderConfigViewManager : view.toolbarMenuItemIconSourceMap = iconSources } - override fun setToolbarMenuElementOptions( + override fun updateToolbarMenuElements( view: StackHeaderConfig, - id: String, - options: ReadableArray, + updates: ReadableArray, ) { - val map = options.getMap(0) ?: return - view.dispatchMenuElementUpdate( - id, - StackHeaderToolbarMenuMapper.parseMenuElementOptions(map), - StackHeaderToolbarMenuMapper.parseMenuElementIconSource(map), - ) + val parsedUpdates = + buildList { + for (i in 0 until updates.size()) { + val map = updates.getMap(i) ?: continue + val id = map.getString("id") ?: continue + add( + Triple( + id, + StackHeaderToolbarMenuMapper.parseMenuElementOptions(map), + StackHeaderToolbarMenuMapper.parseMenuElementIconSource(map), + ), + ) + } + } + view.dispatchMenuElementUpdates(parsedUpdates) } companion object { From b01da2daf71918b793efefccdcb8dc019c9429b8 Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 1 Jul 2026 14:18:20 +0200 Subject: [PATCH 4/5] add SFT --- .../single-feature-tests/stack-v5/index.ts | 3 + .../index.tsx | 178 ++++++++++++++++++ .../scenario-description.ts | 10 + .../scenario.md | 92 +++++++++ 4 files changed, 283 insertions(+) create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/index.tsx create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/scenario-description.ts create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/scenario.md diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts index 40a2cec582..4f045583be 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -17,6 +17,7 @@ import TestStackToolbarMenuTitle from './test-stack-toolbar-menu-title-android'; import TestStackToolbarMenuIcon from './test-stack-toolbar-menu-icon-android'; import TestStackToolbarMenuGroups from './test-stack-toolbar-menu-groups-android'; import TestStackToolbarNestedMenu from './test-stack-toolbar-nested-menu-android'; +import TestStackToolbarMenuBatchCommands from './test-stack-toolbar-menu-batch-commands-android'; import TestStackHeaderSubviewOnPress from './test-stack-header-subview-onpress-ios'; // Scenario entry-point components — each scenario's default export re-exported @@ -36,6 +37,7 @@ export { default as TestStackToolbarMenuShowAsAction } from './test-stack-toolba export { default as TestStackToolbarMenuTitle } from './test-stack-toolbar-menu-title-android'; export { default as TestStackToolbarMenuIcon } from './test-stack-toolbar-menu-icon-android'; export { default as TestStackToolbarNestedMenu } from './test-stack-toolbar-nested-menu-android'; +export { default as TestStackToolbarMenuBatchCommands } from './test-stack-toolbar-menu-batch-commands-android'; const scenarios = { TestStackPreventNativeDismissSingleStack, @@ -54,6 +56,7 @@ const scenarios = { TestStackToolbarMenuTitle, TestStackToolbarMenuIcon, TestStackToolbarNestedMenu, + TestStackToolbarMenuBatchCommands, }; const StackScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/index.tsx new file mode 100644 index 0000000000..be90067a1e --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-batch-commands-android/index.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { Button, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { createScenario } from '@apps/tests/shared/helpers'; +import { + StackContainer, + useStackNavigationContext, +} from '@apps/shared/gamma/containers/stack'; +import { ToastProvider, useToast } from '@apps/shared'; +import { Colors } from '@apps/shared/styling'; +import type { + StackHeaderConfigRef, + StackHeaderToolbarMenuBaseAndroid, +} from 'react-native-screens/experimental'; +import { scenarioDescription } from './scenario-description'; + +const ITEM_IDS = ['apple', 'banana', 'cherry', 'date'] as const; + +const ITEMS_CONFIG: { id: string; title: string }[] = [ + { id: 'apple', title: 'Apple' }, + { id: 'banana', title: 'Banana' }, + { id: 'cherry', title: 'Cherry' }, + { id: 'date', title: 'Date' }, +]; + +function buildMenu( + onGroupChange: (groupId: string, selectedIds: string[]) => void, +): StackHeaderToolbarMenuBaseAndroid { + return { + groups: [ + { + groupId: 'fruits', + singleSelection: false, + onSelectionChange: ids => onGroupChange('fruits', ids), + }, + ], + children: ITEMS_CONFIG.map(({ id, title }) => ({ + type: 'menuItem', + id, + title, + groupId: 'fruits', + initialToggleState: id === 'apple', + })), + }; +} + +const HEADER_TITLE = 'Toolbar Menu Batch Commands Test'; + +function TestStackToolbarMenuBatchCommands() { + return ( + + {}) }, + }, + }, + }, + ]} + /> + + ); +} + +function MainScreen() { + const [lastEvent, setLastEvent] = useState(null); + + const headerConfigRef = useRef(null); + const { setRouteOptions, routeKey } = useStackNavigationContext(); + const toast = useToast(); + + const showToast = useCallback( + (text: string) => { + toast.push({ backgroundColor: Colors.GreenDark120, message: text }); + }, + [toast], + ); + + const handleGroupChange = useCallback( + (groupId: string, selectedIds: string[]) => { + const msg = `${groupId}: ${JSON.stringify(selectedIds)}`; + setLastEvent(msg); + showToast(msg); + }, + [showToast], + ); + + useLayoutEffect(() => { + setRouteOptions(routeKey, { + headerConfig: { + title: HEADER_TITLE, + android: { + toolbarMenu: buildMenu(handleGroupChange), + }, + }, + headerConfigRef, + }); + }, [setRouteOptions, routeKey, handleGroupChange]); + + const selectAll = useCallback(() => { + headerConfigRef.current?.android?.updateToolbarMenuElements( + ITEM_IDS.map(id => ({ id, options: { checked: true } })), + ); + }, []); + + const deselectAll = useCallback(() => { + headerConfigRef.current?.android?.updateToolbarMenuElements( + ITEM_IDS.map(id => ({ id, options: { checked: false } })), + ); + }, []); + + const selectAppleAndCherry = useCallback(() => { + headerConfigRef.current?.android?.updateToolbarMenuElements([ + { id: 'apple', options: { checked: true } }, + { id: 'banana', options: { checked: false } }, + { id: 'cherry', options: { checked: true } }, + { id: 'date', options: { checked: false } }, + ]); + }, []); + + const selectBananaSingle = useCallback(() => { + headerConfigRef.current?.android?.updateToolbarMenuElements({ + id: 'banana', + options: { checked: true }, + }); + }, []); + + return ( + + Batch Commands + +