Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/src/tests/single-feature-tests/stack-v5/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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 TestStackHeaderSubviewOnPress from './test-stack-header-subview-onpress-ios';
import TestStackHeaderSelectiveUpdates from './test-stack-header-selective-updates-ios';

// Scenario entry-point components — each scenario's default export re-exported
// under a name for direct rendering (e.g. from App.tsx or e2e harnesses).
Expand Down Expand Up @@ -46,6 +47,7 @@ const scenarios = {
TestStackSubviewsIOS,
TestStackHeaderMenuIOS,
TestStackHeaderSubviewOnPress,
TestStackHeaderSelectiveUpdates,
TestStackBackButton,
TestStackToolbarMenuCommands,
TestStackToolbarMenuDisabled,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { createScenario } from '@apps/tests/shared/helpers';
import {
StackContainer,
useStackNavigationContext,
} from '@apps/shared/gamma/containers/stack';
import type {
StackHeaderConfigProps,
StackHeaderMenuIOS,
StackHeaderMenuElementIOS,
} from 'react-native-screens/components/gamma/stack/header';
import { Button, ScrollView, StyleSheet, Text, View } from 'react-native';
import { scenarioDescription } from './scenario-description';
import PressableWithFeedback from '@apps/shared/PressableWithFeedback';
import { SettingsSwitch } from '@apps/shared/SettingsSwitch';
import { SettingsPicker } from '@apps/shared/SettingsPicker';
import { ToastProvider, useToast } from '@apps/shared';
import { Colors } from '@apps/shared/styling';

const MENU_MODES = ['none', 'single', 'multi'] as const;
type MenuMode = (typeof MENU_MODES)[number];

interface ItemConfig {
titleVariant: 'foo' | 'bar';
customView: boolean;
menuMode: MenuMode;
}

const DEFAULT_ITEMS: ItemConfig[] = [
{ titleVariant: 'foo', customView: false, menuMode: 'none' },
{ titleVariant: 'foo', customView: false, menuMode: 'none' },
];

const THIRD_ITEM_DEFAULT: ItemConfig = {
titleVariant: 'foo',
customView: false,
menuMode: 'none',
};

function itemTitle(index: number, variant: 'foo' | 'bar'): string {
return variant === 'foo' ? `Foo ${index + 1}` : `Bar ${index + 1}`;
}

function buildMenu(
itemIndex: number,
menuMode: 'single' | 'multi',
showToast: (text: string) => void,
): StackHeaderMenuIOS {
const singleSelection = menuMode === 'single';

const children: StackHeaderMenuElementIOS[] = [
{
id: `Option-${itemIndex}-A`,
type: 'menuItem',
itemType: 'toggle',
initialToggleState: true,
title: `Option-${itemIndex}-A`,
},
{
id: `Option-${itemIndex}-B`,
type: 'menuItem',
itemType: 'toggle',
title: `Option-${itemIndex}-B`,
},
{
id: `Option-${itemIndex}-C`,
type: 'menuItem',
itemType: 'toggle',
title: `Option-${itemIndex}-C`,
},
];

return {
type: 'menu',
id: `menu-${itemIndex}`,
singleSelection,
onSelectionChange: selection =>
showToast(
`Item ${itemIndex + 1} [${menuMode}]: "${selection.join('", "')}"`,
),
children,
};
}

type StackHeaderItems = NonNullable<
StackHeaderConfigProps['ios']
>['trailingItems'];

function buildHeaderConfig(
items: ItemConfig[],
showToast: (text: string) => void,
): StackHeaderConfigProps {
const trailingItems: StackHeaderItems = items.flatMap((item, i) => {
const menu =
item.menuMode !== 'none'
? buildMenu(i, item.menuMode, showToast)
: undefined;

let outItems: StackHeaderItems = [];

if (item.customView) {
outItems.push({
type: 'item',
id: `trailing-${i}`,
render: () => (
<PressableWithFeedback style={{ width: 30, height: 30 }} />
),
menu,
});
} else {
outItems.push({
type: 'item',
id: `trailing-${i}`,
title: itemTitle(i, item.titleVariant),
onPress: () => showToast(`Pressed Item ${i + 1}`),
menu,
});
}

outItems.push({ type: 'spacer', id: `spacer-${i}`, sizing: 'flexible' });

return outItems;
});

return {
title: 'Selective Updates',
ios: {
trailingItems,
},
};
}

export function App() {
return (
<ToastProvider>
<StackContainer
routeConfigs={[
{
name: 'Home',
Component: ConfigScreen,
options: {},
},
]}
/>
</ToastProvider>
);
}

function ConfigScreen() {
const navigation = useStackNavigationContext();
const toast = useToast();
const [items, setItems] = useState<ItemConfig[]>(DEFAULT_ITEMS);
const [showThirdItem, setShowThirdItem] = useState(false);
const [thirdItemConfig, setThirdItemConfig] =
useState<ItemConfig>(THIRD_ITEM_DEFAULT);

const allItems = useMemo(
() => (showThirdItem ? [...items, thirdItemConfig] : items),
[items, showThirdItem, thirdItemConfig],
);

const showToast = useCallback(
(text: string) => {
toast.push({ backgroundColor: Colors.GreenDark120, message: text });
},
[toast],
);

const updateItem = useCallback(
(index: number, update: Partial<ItemConfig>) => {
if (index === 2) {
setThirdItemConfig(prev => ({ ...prev, ...update }));
} else {
setItems(prev =>
prev.map((item, i) => (i === index ? { ...item, ...update } : item)),
);
}
},
[],
);

const { setRouteOptions, routeKey } = navigation;
const headerConfig = useMemo(
() => buildHeaderConfig(allItems, showToast),
[allItems, showToast],
);

useLayoutEffect(() => {
setRouteOptions(routeKey, {
headerConfig,
});
}, [headerConfig, setRouteOptions, routeKey]);

return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
contentInsetAdjustmentBehavior="automatic">
<Text>
On iOS 26 and above, item update is visible as visual flash / blur.
Updating single item should NOT make other items flash. Updating the
menu should NOT make any item flash. Updating the title when custom item
is set should NOT make it flash.
</Text>
{allItems.map((item, i) => (
<View key={i} style={styles.itemSection}>
<Text style={styles.heading}>Item {i + 1}</Text>
<SettingsPicker<'foo' | 'bar'>
label="Title"
value={item.titleVariant}
onValueChange={v => updateItem(i, { titleVariant: v })}
items={['foo', 'bar']}
/>
<SettingsSwitch
label="Custom view"
value={item.customView}
onValueChange={v => updateItem(i, { customView: v })}
/>
<SettingsPicker<MenuMode>
label="Menu"
value={item.menuMode}
onValueChange={v => updateItem(i, { menuMode: v })}
items={[...MENU_MODES]}
/>
</View>
))}
<Button
title={showThirdItem ? 'Remove Item 3' : 'Add Item 3'}
onPress={() => setShowThirdItem(prev => !prev)}
/>
</ScrollView>
);
}

const styles = StyleSheet.create({
scroll: {
backgroundColor: Colors.cardBackground,
},
content: {
padding: 16,
gap: 12,
},
itemSection: {
gap: 4,
borderBottomWidth: 1,
borderBottomColor: Colors.cardBorder,
paddingBottom: 12,
},
heading: {
fontSize: 16,
fontWeight: 'bold',
},
});

export default createScenario(App, scenarioDescription);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ScenarioDescription } from '@apps/tests/shared/helpers';

export const scenarioDescription: ScenarioDescription = {
name: 'Stack Header Selective Updates (iOS)',
key: 'test-stack-header-selective-updates-ios',
details:
'Tests that header item updates are scoped — only the changed item rebuilds. On iOS 26+, item rebuild causes a visible flash/blur on the affected bar button.',
platforms: ['ios'],
e2eCoverage: 'tbd',
smokeTest: false,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Test Scenario: Stack Header Selective Updates (iOS)

## Details

**Description:** This test verifies that updating a single header item does not cause other items to rebuild.

**OS test creation version:** iOS 26.4

## E2E test

TBD

## Prerequisites

- iOS emulator running iOS 26

## Steps on iPhone with iOS 26

1. Observe two items in the trailing area: "Foo 1" and "Foo 2"
2. Change Title picker for Item 1 from "foo" to "bar"
- [ ] Item 1 flashes and changes to "Bar 1"
- [ ] Item 2 does NOT flash
3. Change Menu picker for Item 1 to "single"
- [ ] Neither items flash
4. Open Item 1 menu by long press and select Option-0-B
- [ ] A toast `Item 1 [single]: "Option-0-B"` selected appears
- [ ] When opened again, only Option-0-B is checked
5. Change Menu picker for Item 1 to "multi"
- [ ] Menu can be opened and the default first option is selected
6. Select Option-0-B in Item 1 menu
- [ ] A toast `Item 1 \[multi]: "Option-0-A", "Option-0-B"` selected appears
- [ ] When opened again, both Option-0-A and Option-0-B are checked
7. Enable "Custom view" for Item 1
- [ ] Item 1 flashes and shows a purple pressable square
- [ ] Item 2 aligns with new width and does not flash
8. Change Title picker for Item 1 while custom view is enabled
- [ ] Neither items flash
9. Press "Add Item 3"
- [ ] A third item appears
- [ ] Items 2 and 3 flash
10. Press "Remove Item 3"
- [ ] Items 2 and 3 flash
- [ ] Item 3 disappears under Item 2
16 changes: 15 additions & 1 deletion ios/gamma/stack/header/RNSStackHeaderConfigComponentView.h
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
#pragma once

#import "RNSReactBaseView.h"
#import "RNSStackHeaderConfigDataProviding.h"
#import "RNSStackHeaderEventsDelegate.h"
#import "RNSStackHeaderItemInvalidationDelegate.h"
#import "RNSViewFrameChangeDelegate.h"

NS_ASSUME_NONNULL_BEGIN

@interface RNSStackHeaderConfigComponentView : RNSReactBaseView <RNSViewFrameChangeDelegate>
@interface RNSStackHeaderConfigComponentView : RNSReactBaseView <RNSViewFrameChangeDelegate,
RNSStackHeaderConfigDataProviding,
RNSStackHeaderItemInvalidationDelegate,
RNSStackHeaderEventsDelegate>

@property (nonatomic, readonly, nullable) NSString *title;
@property (nonatomic, readonly, nullable) NSString *subtitle;
@property (nonatomic, readonly) BOOL hidden;
@property (nonatomic, readonly, nullable) NSString *largeTitle;
@property (nonatomic, readonly, nullable) NSString *largeSubtitle;
@property (nonatomic, readonly) BOOL largeTitleEnabled;
@property (nonatomic, readonly) NSArray<id> *children;

- (void)resetProps;

Expand Down
Loading
Loading