diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index aa699de640f12..a33a8adb5fa18 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -6033,14 +6033,16 @@ const CONST = {
NAVIGATE: 'NAVIGATE',
SET_PARAMS: 'SET_PARAMS',
PRELOAD: 'PRELOAD',
+ POP: 'POP',
POP_TO: 'POP_TO',
GO_BACK: 'GO_BACK',
+ RESET: 'RESET',
/** These action types are custom for RootNavigator */
DISMISS_MODAL: 'DISMISS_MODAL',
OPEN_WORKSPACE_SPLIT: 'OPEN_WORKSPACE_SPLIT',
OPEN_DOMAIN_SPLIT: 'OPEN_DOMAIN_SPLIT',
- SET_HISTORY_PARAM: 'SET_HISTORY_PARAM',
+ PUSH_PARAMS: 'PUSH_PARAMS',
REPLACE_PARAMS: 'REPLACE_PARAMS',
TOGGLE_SIDE_PANEL_WITH_HISTORY: 'TOGGLE_SIDE_PANEL_WITH_HISTORY',
},
diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx
index 06f5a83f9c14b..83175b7fc5862 100644
--- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx
+++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx
@@ -40,7 +40,6 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import KeyboardUtils from '@src/utils/keyboard';
-import SearchTypeMenuPopover from './SearchTypeMenuPopover';
// When counting absolute positioning, we need to account for borders
const BORDER_WIDTH = 1;
@@ -54,7 +53,6 @@ type SearchPageHeaderInputProps = {
};
function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, handleSearch}: SearchPageHeaderInputProps) {
- const [showPopupButton, setShowPopupButton] = useState(true);
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
@@ -143,17 +141,8 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
setAutocompleteSubstitutions(substitutionsMap);
}, [allFeeds, personalAndWorkspaceCards, originalInputQuery, personalDetails, reports, taxRates, policies, currentUserAccountID, translate]);
- useEffect(() => {
- if (searchRouterListVisible) {
- return;
- }
- setShowPopupButton(true);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchRouterListVisible]);
-
const onFocus = useCallback(() => {
onSearchRouterFocus?.();
- setShowPopupButton(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -289,11 +278,6 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
onKeyPress={handleKeyPress}
/>
- {showPopupButton && (
-
-
-
- )}
{!!searchRouterListVisible && (
diff --git a/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx b/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx
deleted file mode 100644
index 6c536bafa7a2b..0000000000000
--- a/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import React, {useRef} from 'react';
-import Button from '@components/Button';
-import PopoverMenu from '@components/PopoverMenu';
-import type {SearchQueryJSON} from '@components/Search/types';
-import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
-import useLocalize from '@hooks/useLocalize';
-import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
-import useSearchTypeMenu from '@hooks/useSearchTypeMenu';
-import useThemeStyles from '@hooks/useThemeStyles';
-import CONST from '@src/CONST';
-
-type SearchTypeMenuNarrowProps = {
- queryJSON: SearchQueryJSON;
-};
-
-function SearchTypeMenuPopover({queryJSON}: SearchTypeMenuNarrowProps) {
- const styles = useThemeStyles();
- const {translate} = useLocalize();
- const {isPopoverVisible, delayPopoverMenuFirstRender, openMenu, closeMenu, allMenuItems, windowHeight} = useSearchTypeMenu(queryJSON);
-
- const buttonRef = useRef(null);
- const {unmodifiedPaddings} = useSafeAreaPaddings();
-
- const expensifyIcons = useMemoizedLazyExpensifyIcons(['Menu']);
-
- return (
- <>
-
- {!delayPopoverMenuFirstRender && (
-
- )}
- >
- );
-}
-
-export default SearchTypeMenuPopover;
diff --git a/src/components/TabSelector/TabLabel.tsx b/src/components/TabSelector/TabLabel.tsx
index 266f0603efbbe..e2709d772c3a1 100644
--- a/src/components/TabSelector/TabLabel.tsx
+++ b/src/components/TabSelector/TabLabel.tsx
@@ -1,8 +1,10 @@
import React from 'react';
// eslint-disable-next-line no-restricted-imports
import {Animated, StyleSheet, View} from 'react-native';
+import type {StyleProp, TextStyle} from 'react-native';
import Text from '@components/Text';
import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
type TabLabelProps = {
/** Title of the tab */
@@ -16,17 +18,30 @@ type TabLabelProps = {
/** Whether an icon is present - used to conditionally apply margin */
hasIcon?: boolean;
+
+ /** Text style */
+ textStyle?: StyleProp;
};
-function TabLabel({title = '', activeOpacity = 0, inactiveOpacity = 1, hasIcon = false}: TabLabelProps) {
+function TabLabel({title = '', activeOpacity = 0, inactiveOpacity = 1, hasIcon = false, textStyle = false}: TabLabelProps) {
const styles = useThemeStyles();
return (
-
+
- {title}
+
+ {title}
+
- {title}
+
+ {title}
+
);
diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx
index 660c1f86c0842..bbc49523fed48 100644
--- a/src/components/TabSelector/TabSelector.tsx
+++ b/src/components/TabSelector/TabSelector.tsx
@@ -12,6 +12,7 @@ function TabSelector({
state,
navigation,
onTabPress = () => {},
+ onLongTabPress,
position,
onFocusTrapContainerElementChanged,
shouldShowLabelWhenInactive = true,
@@ -66,6 +67,7 @@ function TabSelector({
tabs={tabs}
activeTabKey={activeRouteName}
onTabPress={handleTabPress}
+ onLongTabPress={onLongTabPress}
position={position}
shouldShowLabelWhenInactive={shouldShowLabelWhenInactive}
shouldShowProductTrainingTooltip={shouldShowProductTrainingTooltip}
diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx
index d7fa8a076b1d8..0721cb6f8a5d0 100644
--- a/src/components/TabSelector/TabSelectorBase.tsx
+++ b/src/components/TabSelector/TabSelectorBase.tsx
@@ -22,6 +22,8 @@ function TabSelectorBase({
tabs,
activeTabKey,
onTabPress = () => {},
+ onLongTabPress,
+ onActiveTabPress = () => {},
position,
shouldShowLabelWhenInactive = true,
equalWidth = false,
@@ -64,6 +66,7 @@ function TabSelectorBase({
contentContainerStyle={styles.tabSelectorContentContainer}
horizontal
showsHorizontalScrollIndicator={false}
+ keyboardShouldPersistTaps="handled"
>
{tabs.map((tab, index) => {
const isActive = index === activeIndex;
@@ -94,9 +97,9 @@ function TabSelectorBase({
const handlePress = () => {
if (isActive) {
+ onActiveTabPress(tab.key);
return;
}
-
setAffectedAnimatedTabs([activeIndex, index]);
onTabPress(tab.key);
};
@@ -108,6 +111,7 @@ function TabSelectorBase({
icon={tab.icon}
title={tab.title}
onPress={handlePress}
+ onLongPress={onLongTabPress ? () => onLongTabPress(tab.key) : undefined}
activeOpacity={activeOpacity}
inactiveOpacity={inactiveOpacity}
backgroundColor={backgroundColor}
@@ -118,6 +122,9 @@ function TabSelectorBase({
shouldShowProductTrainingTooltip={shouldShowProductTrainingTooltip}
renderProductTrainingTooltip={renderProductTrainingTooltip}
equalWidth={equalWidth}
+ badgeText={tab.badgeText}
+ pendingAction={tab.pendingAction}
+ disabled={tab.disabled}
/>
);
})}
diff --git a/src/components/TabSelector/TabSelectorContext.tsx b/src/components/TabSelector/TabSelectorContext.tsx
index 20135228d149f..d0ab17181233d 100644
--- a/src/components/TabSelector/TabSelectorContext.tsx
+++ b/src/components/TabSelector/TabSelectorContext.tsx
@@ -1,4 +1,4 @@
-import React, {createContext, useRef} from 'react';
+import React, {createContext, useEffect, useRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView, View} from 'react-native';
import scrollToTabUtil from './scrollToTab';
@@ -32,6 +32,7 @@ function TabSelectorContextProvider({children, activeTabKey}: TabSelectorContext
const containerRef = useRef(null);
const containerLayoutRef = useRef<{x: number; width: number}>({x: 0, width: 0});
const tabsRef = useRef>({});
+ const lastScrolledToTab = useRef('');
const onContainerLayout = (event: LayoutChangeEvent) => {
const width = event.nativeEvent.layout.width;
@@ -68,11 +69,33 @@ function TabSelectorContextProvider({children, activeTabKey}: TabSelectorContext
return;
}
+ lastScrolledToTab.current = tabKey;
+
const {x: tabX, width: tabWidth, ref: tabRef} = tabData;
scrollToTabUtil({tabX, tabWidth, tabRef, containerRef, containerWidth: containerLayoutRef.current.width, containerX: containerLayoutRef.current.x});
};
+ // In case tab is not changed by tapping on a different tab we still
+ // want to scroll to the selected tab to make sure it's in view
+ useEffect(() => {
+ if (!lastScrolledToTab.current || activeTabKey === lastScrolledToTab.current) {
+ return;
+ }
+
+ lastScrolledToTab.current = activeTabKey;
+
+ const tabData = tabsRef.current[activeTabKey];
+
+ if (!tabData) {
+ return;
+ }
+
+ const {x: tabX, width: tabWidth, ref: tabRef} = tabData;
+
+ scrollToTabUtil({tabX, tabWidth, tabRef, containerRef, containerWidth: containerLayoutRef.current.width, containerX: containerLayoutRef.current.x});
+ }, [activeTabKey]);
+
// React Compiler auto-memoization
// eslint-disable-next-line react/jsx-no-constructed-context-values
const contextValue = {
diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx
index 0b6c290736bc8..11e9778693250 100644
--- a/src/components/TabSelector/TabSelectorItem.tsx
+++ b/src/components/TabSelector/TabSelectorItem.tsx
@@ -2,9 +2,11 @@ import React, {useContext, useState} from 'react';
// eslint-disable-next-line no-restricted-imports
import {Animated} from 'react-native';
import type {View} from 'react-native';
+import Badge from '@components/Badge';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Tooltip from '@components/Tooltip';
import EducationalTooltip from '@components/Tooltip/EducationalTooltip';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -22,6 +24,7 @@ function TabSelectorItem({
icon,
title = '',
onPress = () => {},
+ onLongPress,
backgroundColor = '',
activeOpacity = 0,
inactiveOpacity = 1,
@@ -32,7 +35,12 @@ function TabSelectorItem({
shouldShowProductTrainingTooltip = false,
renderProductTrainingTooltip,
equalWidth = false,
+ badgeText,
+ disabled = false,
+ pendingAction,
}: TabSelectorItemProps) {
+ const {isOffline} = useNetwork();
+
const styles = useThemeStyles();
const [isHovered, setIsHovered] = useState(false);
const shouldShowEducationalTooltip = shouldShowProductTrainingTooltip && isActive;
@@ -41,14 +49,23 @@ function TabSelectorItem({
const accessibilityState = {selected: isActive};
+ const isOfflinePendingAction = !!isOffline && !!pendingAction;
+ const needsStrikeThrough = isOffline && pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+
const children = (
registerTab(tabKey, ref)}
accessibilityLabel={title}
accessibilityState={accessibilityState}
accessibilityRole={CONST.ROLE.TAB}
- style={[styles.tabSelectorButton, styles.tabBackground(isHovered, isActive, backgroundColor), styles.userSelectNone]}
- wrapperStyle={[equalWidth ? styles.flex1 : styles.flexGrow1]}
+ style={[
+ styles.tabSelectorButton,
+ styles.tabBackground(isHovered, isActive, disabled, backgroundColor),
+ styles.userSelectNone,
+ isOfflinePendingAction && styles.offlineFeedbackPending,
+ ]}
+ wrapperStyle={equalWidth ? styles.flex1 : styles.flexGrow1}
+ onLongPress={onLongPress}
onPress={() => {
scrollToTab(tabKey);
onPress();
@@ -60,20 +77,28 @@ function TabSelectorItem({
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
testID={testID}
sentryLabel={sentryLabel}
+ disabled={disabled}
>
{(shouldShowLabelWhenInactive || isActive) && (
)}
+ {!!badgeText && (
+
+ )}
);
diff --git a/src/components/TabSelector/types.ts b/src/components/TabSelector/types.ts
index 627440c23a366..ab954b1c74866 100644
--- a/src/components/TabSelector/types.ts
+++ b/src/components/TabSelector/types.ts
@@ -2,6 +2,7 @@ import type {MaterialTopTabBarProps} from '@react-navigation/material-top-tabs';
// eslint-disable-next-line no-restricted-imports
import type {Animated} from 'react-native';
import type {ThemeColors} from '@styles/theme/types';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
import type IconAsset from '@src/types/utils/IconAsset';
import type WithSentryLabel from '@src/types/utils/SentryLabel';
@@ -9,6 +10,9 @@ type TabSelectorProps = MaterialTopTabBarProps & {
/* Callback fired when tab is pressed */
onTabPress?: (name: string) => void;
+ /* Callback fired when tab is long pressed */
+ onLongTabPress?: (key: string) => void;
+
/** Callback to register focus trap container element */
onFocusTrapContainerElementChanged?: (element: HTMLElement | null) => void;
@@ -37,6 +41,15 @@ type TabSelectorBaseItem = WithSentryLabel & {
/** Test identifier used to find elements in tests. */
testID?: string;
+
+ /** Text to display on the badge on the tab. */
+ badgeText?: string;
+
+ /** Whether this tab is disabled */
+ disabled?: boolean;
+
+ /** Pending action for the tab. */
+ pendingAction?: PendingAction;
};
type TabSelectorBaseProps = {
@@ -49,6 +62,12 @@ type TabSelectorBaseProps = {
/** Called when a tab is pressed with its key. */
onTabPress?: (key: string) => void;
+ /* Callback fired when tab is long pressed */
+ onLongTabPress?: (key: string) => void;
+
+ /* Callback fired when active tab is pressed */
+ onActiveTabPress?: (key: string) => void;
+
/** Animated position from a navigator (optional). */
position?: Animated.AnimatedInterpolation;
@@ -72,6 +91,9 @@ type TabSelectorItemProps = WithSentryLabel & {
/** Function to call when onPress */
onPress?: () => void;
+ /* Callback fired when tab is long pressed */
+ onLongPress?: () => void;
+
/** Icon to display on tab */
icon?: IconAsset;
@@ -104,6 +126,15 @@ type TabSelectorItemProps = WithSentryLabel & {
/** Function to render the content of the product training tooltip. */
renderProductTrainingTooltip?: () => React.JSX.Element;
+
+ /** Text to display on the badge on the tab. */
+ badgeText?: string;
+
+ /** Whether this tab is disabled */
+ disabled?: boolean;
+
+ /** Pending action for the tab. */
+ pendingAction?: PendingAction;
};
type AnimationConfigBase = {
diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx
index b6138ecd540c5..0ad0a2d986839 100644
--- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx
+++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx
@@ -1,6 +1,7 @@
import type {NavigationProp, NavigatorTypeBagBase, ParamListBase, StaticConfig, TypedNavigator} from '@react-navigation/native';
import {createNavigatorFactory} from '@react-navigation/native';
import RootNavigatorExtraContent from '@components/Navigation/RootNavigatorExtraContent';
+import addSidebarRouterExtension from '@libs/Navigation/AppNavigator/routerExtensions/addSidebarRouterExtension';
import useNavigationResetOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange';
import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent';
import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions';
@@ -9,7 +10,7 @@ import RootStackRouter from './RootStackRouter';
import useCustomRootStackNavigatorState from './useCustomRootStackNavigatorState';
const RootStackNavigatorComponent = createPlatformStackNavigatorComponent('RootStackNavigator', {
- createRouter: RootStackRouter,
+ createRouter: addSidebarRouterExtension(RootStackRouter),
defaultScreenOptions: defaultPlatformStackScreenOptions,
useCustomEffects: useNavigationResetOnLayoutChange,
useCustomState: useCustomRootStackNavigatorState,
diff --git a/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/index.tsx
index 9ac28744a5caa..786027116ff73 100644
--- a/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/index.tsx
+++ b/src/libs/Navigation/AppNavigator/createSearchFullscreenNavigator/index.tsx
@@ -2,6 +2,7 @@ import type {NavigationProp, NavigatorTypeBagBase, ParamListBase, StaticConfig,
import {createNavigatorFactory} from '@react-navigation/native';
import SearchSidebar from '@components/Navigation/SearchSidebar';
import usePreserveNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState';
+import addPushParamsRouterExtension from '@libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension';
import useNavigationResetOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange';
import createPlatformStackNavigatorComponent from '@navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent';
import defaultPlatformStackScreenOptions from '@navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions';
@@ -15,7 +16,7 @@ function useCustomEffects(props: CustomEffectsHookProps) {
}
const SearchFullscreenNavigatorComponent = createPlatformStackNavigatorComponent('SearchFullscreenNavigator', {
- createRouter: SearchFullscreenRouter,
+ createRouter: addPushParamsRouterExtension(SearchFullscreenRouter),
defaultScreenOptions: defaultPlatformStackScreenOptions,
useCustomEffects,
useCustomState,
diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts
index 397388f736932..a5673e8b82521 100644
--- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts
+++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts
@@ -116,9 +116,19 @@ function shouldPopEntireNavigator(state: StackState, action: CommonActions.Actio
function SplitRouter(options: SplitNavigatorRouterOptions) {
const stackRouter = StackRouter(options);
+
+ const getRehydratedState = (partialState: StackState, configOptions: RouterConfigOptions): StackNavigationState => {
+ const adaptedState = adaptStateIfNecessary({state: partialState, options});
+ return stackRouter.getRehydratedState(adaptedState, configOptions);
+ };
+
return {
...stackRouter,
getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) {
+ if (action.type === CONST.NAVIGATION.ACTION_TYPE.RESET) {
+ const result = stackRouter.getStateForAction(state, action, configOptions);
+ return result ? getRehydratedState(result, configOptions) : result;
+ }
if (isPushingSidebarOnCentralPane(state, action, options)) {
if (getIsNarrowLayout()) {
const newAction = StackActions.popToTop();
@@ -147,10 +157,7 @@ function SplitRouter(options: SplitNavigatorRouterOptions) {
return maybeAdaptedState as StackNavigationState;
},
- getRehydratedState(partialState: StackState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState {
- const adaptedState = adaptStateIfNecessary({state: partialState, options});
- return stackRouter.getRehydratedState(adaptedState, {routeNames, routeParamList, routeGetIdList});
- },
+ getRehydratedState,
};
}
diff --git a/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts b/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts
deleted file mode 100644
index d3c26ef4bbd6e..0000000000000
--- a/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import {CommonActions, findFocusedRoute} from '@react-navigation/native';
-import type {ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native';
-import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types';
-import type {SetParamsAction} from '@libs/Navigation/types';
-import CONST from '@src/CONST';
-import type {HistoryStackNavigatorAction, SetHistoryParamActionType} from './types';
-
-const CUSTOM_HISTORY_PREFIX = 'CUSTOM_HISTORY';
-
-function isSetParamsAction(action: HistoryStackNavigatorAction): action is SetParamsAction {
- return action.type === CONST.NAVIGATION.ACTION_TYPE.SET_PARAMS;
-}
-
-function isSetHistoryParamAction(action: HistoryStackNavigatorAction): action is SetHistoryParamActionType {
- return action.type === CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM;
-}
-
-// The history can be anything. For now, string is enough but we can extend it to include more data if necessary.
-function getCustomHistoryEntry(routeName: string) {
- return `${CUSTOM_HISTORY_PREFIX}-${routeName}`;
-}
-
-/**
- * Higher-order function that extends the React Navigation stack router with custom history functionality.
- * It allows tracking and managing navigation history entries that are not determined by the routes of navigator.
- * The extension adds support for custom history entries through route params and maintains a history stack
- * that can be manipulated independently of the navigation state.
- *
- * @param originalStackRouter - The original stack router function to be extended
- * @returns Enhanced router with custom history functionality
- */
-
-function addCustomHistoryRouterExtension(
- originalRouter: PlatformStackRouterFactory,
-) {
- return (options: RouterOptions): Router, HistoryStackNavigatorAction> => {
- const router = originalRouter(options);
-
- const enhanceStateWithHistory = (state: PlatformStackNavigationState) => {
- return {
- ...state,
- history: state.routes.map((route) => route.key),
- };
- };
-
- // Override methods to enhance state with history
- const getInitialState = (configOptions: RouterConfigOptions) => {
- const state = router.getInitialState(configOptions);
- return enhanceStateWithHistory(state);
- };
-
- const getRehydratedState = (partialState: PartialState>, configOptions: RouterConfigOptions) => {
- const state = router.getRehydratedState(partialState, configOptions);
- const stateWithInitialHistory = enhanceStateWithHistory(state);
-
- const focusedRoute = findFocusedRoute(stateWithInitialHistory);
-
- // There always be a focused route in the state. It's for type safety.
- if (!focusedRoute) {
- return stateWithInitialHistory;
- }
-
- // Custom history param used to show the side panel is handled here
- if (state.history?.at(-1) === CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL) {
- stateWithInitialHistory.history = [...stateWithInitialHistory.history, CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL];
- return stateWithInitialHistory;
- }
-
- return stateWithInitialHistory;
- };
-
- const getStateForAction = (
- state: PlatformStackNavigationState,
- action: CommonActions.Action | StackActionType | HistoryStackNavigatorAction,
- configOptions: RouterConfigOptions,
- ) => {
- // We want to set the right param and then update the history
- if (isSetHistoryParamAction(action)) {
- const customHistoryEntry = getCustomHistoryEntry(action.payload.key);
-
- // Start with updating the param.
- const setParamsAction = CommonActions.setParams({[action.payload.key]: action.payload.value});
- const stateWithUpdatedParams = router.getStateForAction(state, setParamsAction, configOptions);
-
- // This shouldn't ever happen as the history should be always defined. It's for type safety.
- if (!stateWithUpdatedParams?.history) {
- return stateWithUpdatedParams;
- }
-
- // If it's set to true, we need to add the history entry if it's not already there.
- if (action.payload.value && stateWithUpdatedParams.history.at(-1) !== customHistoryEntry) {
- return {...stateWithUpdatedParams, history: [...stateWithUpdatedParams.history, customHistoryEntry]};
- }
-
- // If it's set to false, we need to remove the history entry if it's there.
- if (!action.payload.value) {
- return {...stateWithUpdatedParams, history: stateWithUpdatedParams.history.filter((entry) => entry !== customHistoryEntry)};
- }
-
- // Else, do not change history.
- return stateWithUpdatedParams;
- }
-
- const newState = router.getStateForAction(state, action, configOptions);
-
- // If the action was not handled, return null.
- if (!newState) {
- return null;
- }
-
- // If the action was a setParams action, we need to preserve the history.
- if (isSetParamsAction(action) && state.history) {
- return {
- ...newState,
- history: [...state.history],
- };
- }
-
- // Handle every other action.
- // @ts-expect-error newState can be partial or not. But getRehydratedState will handle it correctly even if the stale === false.
- // And we need to update the history if routes have changed.
- return getRehydratedState(newState, configOptions);
- };
-
- return {
- ...router,
- getInitialState,
- getRehydratedState,
- getStateForAction,
- };
- };
-}
-
-export default addCustomHistoryRouterExtension;
diff --git a/src/libs/Navigation/AppNavigator/customHistory/index.ts b/src/libs/Navigation/AppNavigator/customHistory/index.ts
deleted file mode 100644
index 674be7763434f..0000000000000
--- a/src/libs/Navigation/AppNavigator/customHistory/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// eslint-disable-next-line import/prefer-default-export
-export {default as addCustomHistoryRouterExtension} from './addCustomHistoryRouterExtension';
diff --git a/src/libs/Navigation/AppNavigator/customHistory/types.ts b/src/libs/Navigation/AppNavigator/customHistory/types.ts
deleted file mode 100644
index cd84971f4edf3..0000000000000
--- a/src/libs/Navigation/AppNavigator/customHistory/types.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type {CommonActions, StackActionType} from '@react-navigation/native';
-import type CONST from '@src/CONST';
-
-type HistoryStackNavigatorAction = CommonActions.Action | StackActionType | HistoryStackNavigatorActionType;
-
-type HistoryStackNavigatorActionType = {
- type: typeof CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM;
- payload: {
- key: string;
- value: boolean;
- };
-};
-
-type SetHistoryParamActionType = HistoryStackNavigatorAction & {
- type: typeof CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM;
-};
-
-export type {HistoryStackNavigatorAction, HistoryStackNavigatorActionType, SetHistoryParamActionType};
diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts b/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts
new file mode 100644
index 0000000000000..6320fd1fb521e
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts
@@ -0,0 +1,179 @@
+import {CommonActions} from '@react-navigation/native';
+import type {NavigationRoute, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native';
+import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {GoBackAction, SetParamsAction} from '@libs/Navigation/types';
+import CONST from '@src/CONST';
+import type {CustomHistoryEntry, PushParamsActionType, PushParamsRouterAction} from './types';
+import {enhanceStateWithHistory} from './utils';
+
+function preserveHistoryForRoutes(oldHistory: CustomHistoryEntry[], routes: Array<{key?: string}>): CustomHistoryEntry[] {
+ const remainingKeys = new Set(routes.map((r) => r.key));
+ return oldHistory.filter((entry) => typeof entry === 'string' || remainingKeys.has(entry.key));
+}
+
+function isSetParamsAction(action: PushParamsRouterAction): action is SetParamsAction {
+ return action.type === CONST.NAVIGATION.ACTION_TYPE.SET_PARAMS;
+}
+
+function isPushParamsAction(action: PushParamsRouterAction): action is PushParamsActionType {
+ return action.type === CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS;
+}
+
+function isGoBackAction(action: PushParamsRouterAction): action is GoBackAction {
+ return action.type === CONST.NAVIGATION.ACTION_TYPE.GO_BACK;
+}
+
+function isPopAction(action: PushParamsRouterAction): boolean {
+ return action.type === CONST.NAVIGATION.ACTION_TYPE.POP;
+}
+
+/**
+ * Higher-order function that extends a stack router with push-params history functionality.
+ * It maintains a separate history stack of route snapshots that can diverge from the routes array,
+ * enabling back-navigation through param changes (via PUSH_PARAMS) without requiring additional routes.
+ *
+ * This extension handles:
+ * - PUSH_PARAMS: sets params on the focused route and appends a snapshot to history
+ * - GO_BACK/POP: reverts params to the previous snapshot when surplus history exists for the same route
+ * - SET_PARAMS: preserves existing history unchanged
+ * - RESET: preserves history entries for routes that survive the reset
+ *
+ * TODO: Remove this custom history extension after upgrading to React Navigation 8,
+ * which has built-in support for a PUSH_PARAMS-like action.
+ *
+ * NOTE: The PUSH_PARAMS approach is heuristic and only works in the current setup for the
+ * SearchFullscreenNavigator. It may break if new screens are added to that navigator or if
+ * other structural changes are made to the navigation hierarchy.
+ */
+function addPushParamsRouterExtension(
+ originalRouter: PlatformStackRouterFactory,
+) {
+ return (options: RouterOptions): Router, PushParamsRouterAction> => {
+ const router = originalRouter(options);
+
+ const getInitialState = (configOptions: RouterConfigOptions) => {
+ const state = router.getInitialState(configOptions);
+ return enhanceStateWithHistory(state);
+ };
+
+ const getRehydratedState = (partialState: PartialState>, configOptions: RouterConfigOptions) => {
+ const state = router.getRehydratedState(partialState, configOptions);
+ return enhanceStateWithHistory(state);
+ };
+
+ const getStateForAction = (
+ state: PlatformStackNavigationState,
+ action: CommonActions.Action | StackActionType | PushParamsRouterAction,
+ configOptions: RouterConfigOptions,
+ ) => {
+ if (isPushParamsAction(action)) {
+ const setParamsAction = CommonActions.setParams(action.payload.params);
+ const stateWithUpdatedParams = router.getStateForAction(state, setParamsAction, configOptions);
+
+ if (!stateWithUpdatedParams?.history) {
+ return stateWithUpdatedParams;
+ }
+
+ const lastRoute = stateWithUpdatedParams.routes.at(-1);
+
+ if (lastRoute) {
+ return {...stateWithUpdatedParams, history: [...stateWithUpdatedParams.history, lastRoute]};
+ }
+
+ return stateWithUpdatedParams;
+ }
+
+ // On native there is no browser history, so GO_BACK/POP operate on state.routes which
+ // PUSH_PARAMS never grew. Without this intercept the StackRouter would either pop the
+ // entire screen (if routes.length > 1) or return null and bubble the action up to the
+ // parent navigator. Instead, we consume the action here by reverting params to the
+ // previous history snapshot — mirroring what the browser does on web via popstate.
+ if ((isGoBackAction(action) || isPopAction(action)) && state.history) {
+ const routeHistoryEntries = state.history.filter((entry): entry is NavigationRoute => typeof entry !== 'string');
+
+ if (routeHistoryEntries.length > state.routes.length) {
+ const lastTwo = routeHistoryEntries.slice(-2);
+
+ // Only revert params when the last two history snapshots share the same route key,
+ // meaning they are consecutive PUSH_PARAMS snapshots of the same screen. If the
+ // keys differ, a different screen sits on top (e.g. Search{q=A} -> Search{q=B} -> OtherPage)
+ // and standard POP should remove that screen instead.
+ if (lastTwo.length === 2 && lastTwo.at(0)?.key === lastTwo.at(1)?.key) {
+ const newHistory = [...state.history];
+ newHistory.pop();
+
+ const lastRoute = state.routes.at(-1);
+ if (lastRoute) {
+ const routes = [...state.routes];
+ routes[state.routes.length - 1] = {
+ ...lastRoute,
+ params: lastTwo.at(0)?.params,
+ };
+
+ return {
+ ...state,
+ routes,
+ history: newHistory,
+ };
+ }
+ }
+ }
+
+ // Keys didn't match or no surplus for the focused route — let the StackRouter
+ // handle the pop normally, but preserve history entries for routes that survive
+ // so PUSH_PARAMS snapshots aren't wiped by getRehydratedState.
+ const newState = router.getStateForAction(state, action, configOptions);
+ if (!newState) {
+ return null;
+ }
+ return {
+ ...newState,
+ history: preserveHistoryForRoutes(state.history as CustomHistoryEntry[], newState.routes),
+ };
+ }
+
+ const newState = router.getStateForAction(state, action, configOptions);
+
+ if (!newState) {
+ return null;
+ }
+
+ // SET_PARAMS should not alter the history stack — keep the existing history as-is.
+ if (isSetParamsAction(action) && state.history) {
+ return {
+ ...newState,
+ history: [...state.history],
+ };
+ }
+
+ // For all other actions, rebuild history from the updated routes.
+ // @ts-expect-error newState may be partial, but getRehydratedState handles both partial and full states correctly.
+ const rehydratedState = getRehydratedState(newState, configOptions);
+
+ // RESET actions (fired by web URL sync after PUSH_PARAMS changes the URL) would
+ // normally rebuild history 1:1 from routes via getRehydratedState, wiping all
+ // PUSH_PARAMS snapshots. Preserve history entries for routes that still exist
+ // in the rehydrated state (which may have added routes, e.g. for wide layout).
+ if (action.type === CONST.NAVIGATION.ACTION_TYPE.RESET && state.history) {
+ const preservedHistory = preserveHistoryForRoutes(state.history as CustomHistoryEntry[], rehydratedState.routes);
+ if (preservedHistory.length > 0) {
+ return {
+ ...rehydratedState,
+ history: preservedHistory,
+ };
+ }
+ }
+
+ return rehydratedState;
+ };
+
+ return {
+ ...router,
+ getInitialState,
+ getRehydratedState,
+ getStateForAction,
+ };
+ };
+}
+
+export default addPushParamsRouterExtension;
diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/addSidebarRouterExtension.ts b/src/libs/Navigation/AppNavigator/routerExtensions/addSidebarRouterExtension.ts
new file mode 100644
index 0000000000000..f0262594e19d6
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/routerExtensions/addSidebarRouterExtension.ts
@@ -0,0 +1,54 @@
+import type {CommonActions, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native';
+import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types';
+import CONST from '@src/CONST';
+import {enhanceStateWithHistory} from './utils';
+
+/**
+ * Higher-order function that extends a React Navigation stack router with sidebar history functionality.
+ * It maintains a `history` array mirroring the routes, and preserves the CUSTOM_HISTORY_ENTRY_SIDE_PANEL
+ * entry through rehydration so the side panel open/close state survives navigation state rebuilds.
+ *
+ * This extension is intended only for the root stack navigator.
+ */
+function addSidebarRouterExtension(originalRouter: PlatformStackRouterFactory) {
+ return (options: RouterOptions): Router, CommonActions.Action | StackActionType> => {
+ const router = originalRouter(options);
+
+ const getInitialState = (configOptions: RouterConfigOptions) => {
+ const state = router.getInitialState(configOptions);
+ return enhanceStateWithHistory(state);
+ };
+
+ const getRehydratedState = (partialState: PartialState>, configOptions: RouterConfigOptions) => {
+ const state = router.getRehydratedState(partialState, configOptions);
+ const stateWithInitialHistory = enhanceStateWithHistory(state);
+
+ if (state.history?.at(-1) === CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL) {
+ stateWithInitialHistory.history = [...stateWithInitialHistory.history, CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL];
+ return stateWithInitialHistory;
+ }
+
+ return stateWithInitialHistory;
+ };
+
+ const getStateForAction = (state: PlatformStackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) => {
+ const newState = router.getStateForAction(state, action, configOptions);
+
+ if (!newState) {
+ return null;
+ }
+
+ // @ts-expect-error newState may be partial, but getRehydratedState handles both partial and full states correctly.
+ return getRehydratedState(newState, configOptions);
+ };
+
+ return {
+ ...router,
+ getInitialState,
+ getRehydratedState,
+ getStateForAction,
+ };
+ };
+}
+
+export default addSidebarRouterExtension;
diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/index.ts b/src/libs/Navigation/AppNavigator/routerExtensions/index.ts
new file mode 100644
index 0000000000000..1b195fe664c7e
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/routerExtensions/index.ts
@@ -0,0 +1,2 @@
+export {default as addSidebarRouterExtension} from './addSidebarRouterExtension';
+export {default as addPushParamsRouterExtension} from './addPushParamsRouterExtension';
diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/types.ts b/src/libs/Navigation/AppNavigator/routerExtensions/types.ts
new file mode 100644
index 0000000000000..ccc1dfdc2373c
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/routerExtensions/types.ts
@@ -0,0 +1,15 @@
+import type {CommonActions, NavigationRoute, ParamListBase, StackActionType} from '@react-navigation/native';
+import type CONST from '@src/CONST';
+
+type PushParamsActionType = {
+ type: typeof CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS;
+ payload: {
+ params: Record;
+ };
+};
+
+type PushParamsRouterAction = CommonActions.Action | StackActionType | PushParamsActionType;
+
+type CustomHistoryEntry = NavigationRoute | string;
+
+export type {PushParamsRouterAction, PushParamsActionType, CustomHistoryEntry};
diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/utils.ts b/src/libs/Navigation/AppNavigator/routerExtensions/utils.ts
new file mode 100644
index 0000000000000..63b262c695e8a
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/routerExtensions/utils.ts
@@ -0,0 +1,13 @@
+import type {ParamListBase} from '@react-navigation/native';
+import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {CustomHistoryEntry} from './types';
+
+function enhanceStateWithHistory(state: PlatformStackNavigationState) {
+ return {
+ ...state,
+ history: state.routes.map((route) => ({...route})) as CustomHistoryEntry[],
+ };
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export {enhanceStateWithHistory};
diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx
index ce41090ab3494..ee0f70d7744a6 100644
--- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx
+++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx
@@ -3,7 +3,6 @@ import {StackRouter, useNavigationBuilder} from '@react-navigation/native';
import {NativeStackView} from '@react-navigation/native-stack';
import type {NativeStackNavigationEventMap, NativeStackNavigationOptions} from '@react-navigation/native-stack';
import React, {useMemo} from 'react';
-import {addCustomHistoryRouterExtension} from '@libs/Navigation/AppNavigator/customHistory';
import convertToNativeNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToNativeNavigationOptions';
import type {
CreatePlatformStackNavigatorComponentOptions,
@@ -18,7 +17,7 @@ function createPlatformStackNavigatorComponent,
) {
- const createRouter = addCustomHistoryRouterExtension(options?.createRouter ?? StackRouter);
+ const createRouter = options?.createRouter ?? StackRouter;
const defaultScreenOptions = options?.defaultScreenOptions;
const useCustomState = options?.useCustomState ?? (() => undefined);
const useCustomEffects = options?.useCustomEffects ?? (() => undefined);
diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx
index 495ca5c25d390..c00d23245047e 100644
--- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx
+++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx
@@ -3,7 +3,6 @@ import {StackRouter, useNavigationBuilder} from '@react-navigation/native';
import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack';
import {StackView} from '@react-navigation/stack';
import React, {useMemo} from 'react';
-import {addCustomHistoryRouterExtension} from '@libs/Navigation/AppNavigator/customHistory';
import convertToWebNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions';
import type {
CreatePlatformStackNavigatorComponentOptions,
@@ -19,7 +18,7 @@ function createPlatformStackNavigatorComponent,
) {
- const createRouter = addCustomHistoryRouterExtension(options?.createRouter ?? StackRouter);
+ const createRouter = options?.createRouter ?? StackRouter;
const useCustomState = options?.useCustomState ?? (() => undefined);
const defaultScreenOptions = options?.defaultScreenOptions;
const ExtraContent = options?.ExtraContent;
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 072269fe167f3..d3a96eafc8c28 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -3211,6 +3211,7 @@ export type {
FeatureTrainingNavigatorParamList,
FlagCommentNavigatorParamList,
FullScreenName,
+ GoBackAction,
MissingPersonalDetailsParamList,
MoneyRequestNavigatorParamList,
NavigationPartialRoute,
diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx
index 5ca1fd3deb1e6..da81c39e345cc 100644
--- a/src/pages/Search/SearchPageNarrow.tsx
+++ b/src/pages/Search/SearchPageNarrow.tsx
@@ -37,6 +37,7 @@ import {search} from '@userActions/Search';
import ROUTES from '@src/ROUTES';
import type {SearchResults} from '@src/types/onyx';
import type {SearchResultsInfo} from '@src/types/onyx/SearchResults';
+import SearchPageTabSelector from './SearchPageTabSelector';
const TOO_CLOSE_TO_TOP_DISTANCE = 10;
const TOO_CLOSE_TO_BOTTOM_DISTANCE = 10;
@@ -199,6 +200,12 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable
styles.searchTopBarZIndexStyle,
]}
>
+ {
+ setSearchRouterListVisible(false);
+ }}
+ />
void;
+};
+
+function SearchPageTabSelector({queryJSON, onTabPress}: SearchPageTabSelectorProps) {
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+ const styles = useThemeStyles();
+ const navigation = useNavigation();
+ const {typeMenuSections} = useSearchTypeMenuSections();
+ const personalDetails = usePersonalDetails();
+ const feedKeysWithCards = useFeedKeysWithAssignedCards();
+ const [restoreFocusType, setRestoreFocusType] = useState();
+
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER);
+ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const [cardList] = useOnyx(ONYXKEYS.CARD_LIST);
+ const [workspaceCardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST);
+ const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES);
+ const [reportCounts = CONST.EMPTY_TODOS_REPORT_COUNTS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosReportCountsSelector});
+ const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector});
+
+ const taxRates = getAllTaxRates(allPolicies);
+ const cardsForSavedSearchDisplay = mergeCardListWithWorkspaceFeeds(workspaceCardList ?? CONST.EMPTY_OBJECT, cardList);
+
+ const [savedSearchToModifyKey, setSavedSearchToModifyKey] = useState(null);
+ const menuAnchorRef = useRef(null);
+ const {showDeleteModal} = useDeleteSavedSearch();
+
+ const expensifyIcons = useMemoizedLazyExpensifyIcons([
+ 'Receipt',
+ 'ChatBubbles',
+ 'MoneyBag',
+ 'CreditCard',
+ 'MoneyHourglass',
+ 'CreditCardHourglass',
+ 'Bank',
+ 'User',
+ 'Folder',
+ 'Basket',
+ 'CalendarSolid',
+ 'Bookmark',
+ 'ExpensifyCard',
+ 'Pencil',
+ ] as const);
+
+ const queryMap = new Map();
+ const tabItems: TabSelectorBaseItem[] = [];
+ const savedSearchesPopoverMenuItems: Record = {};
+ let activeKey = '';
+
+ const savedSearchesTabItems: TabSelectorBaseItem[] = savedSearches
+ ? Object.entries(savedSearches)
+ .map(([key, item]): TabSelectorBaseItem | null => {
+ if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) {
+ return null;
+ }
+
+ let title = item.name;
+ const itemJsonQuery = buildSearchQueryJSON(item.query);
+ if (queryJSON && itemJsonQuery && title === item.query) {
+ title = buildUserReadableQueryString({
+ queryJSON: itemJsonQuery,
+ PersonalDetails: personalDetails,
+ reports,
+ taxRates,
+ cardList: cardsForSavedSearchDisplay,
+ cardFeeds: allFeeds,
+ policies: allPolicies,
+ currentUserAccountID,
+ autoCompleteWithSpace: false,
+ translate,
+ feedKeysWithCards,
+ });
+ }
+
+ queryMap.set(key, {query: item.query ?? '', name: item.name});
+ savedSearchesPopoverMenuItems[key] = getOverflowMenu(expensifyIcons, title, Number(key), item.query, translate, showDeleteModal, true, () =>
+ setSavedSearchToModifyKey(null),
+ );
+
+ if (queryJSON && Number(key) === queryJSON.hash) {
+ activeKey = key;
+ }
+
+ return {
+ key,
+ icon: expensifyIcons.Bookmark,
+ title,
+ disabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ pendingAction: item.pendingAction,
+ };
+ })
+ .filter((item) => item !== null)
+ : [];
+
+ for (const section of typeMenuSections) {
+ if (section.translationPath === 'search.savedSearchesMenuItemTitle') {
+ tabItems.push(...savedSearchesTabItems);
+ } else {
+ for (const item of section.menuItems) {
+ const icon = typeof item.icon === 'string' ? expensifyIcons[item.icon] : item.icon;
+ const badgeText = getItemBadgeText(item.key, reportCounts);
+ const title = translate(item.translationPath);
+
+ tabItems.push({
+ key: item.key,
+ icon,
+ title,
+ badgeText,
+ });
+ queryMap.set(item.key, {query: item.searchQuery});
+ if (queryJSON && item.similarSearchHash === queryJSON.similarSearchHash) {
+ activeKey = item.key;
+ }
+ }
+ }
+ }
+
+ const popoverMenuItems = savedSearchToModifyKey ? savedSearchesPopoverMenuItems?.[savedSearchToModifyKey] : [];
+ const shouldShowSavedSearchPopover = savedSearchToModifyKey && popoverMenuItems.length > 0;
+
+ const handleActiveTabPress = (tabKey: string) => {
+ const searchData = queryMap.get(tabKey);
+ if (!searchData) {
+ return;
+ }
+ onTabPress?.();
+ setSearchContext(false);
+ };
+
+ const handleTabPress = (tabKey: string) => {
+ const searchData = queryMap.get(tabKey);
+ if (!searchData) {
+ return;
+ }
+ onTabPress?.();
+ setSearchContext(false);
+ navigation.dispatch({
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS,
+ payload: {
+ params: {q: searchData.query, name: searchData.name, rawQuery: undefined},
+ },
+ });
+ };
+
+ const handleLongTabPress = (tabKey: string) => {
+ if (!savedSearchesPopoverMenuItems?.[tabKey]) {
+ return;
+ }
+
+ setSavedSearchToModifyKey(tabKey);
+ };
+
+ return (
+
+
+
+
+ setSavedSearchToModifyKey(null)}
+ onModalHide={() => setRestoreFocusType(undefined)}
+ isVisible={!!shouldShowSavedSearchPopover}
+ anchorPosition={{horizontal: 0, vertical: 0}}
+ anchorAlignment={{
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
+ }}
+ onItemSelected={() => {
+ setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE);
+ setSavedSearchToModifyKey(null);
+ }}
+ menuItems={popoverMenuItems}
+ anchorRef={menuAnchorRef}
+ shouldEnableNewFocusManagement
+ restoreFocusType={restoreFocusType}
+ />
+
+ );
+}
+
+export default SearchPageTabSelector;
diff --git a/src/styles/index.ts b/src/styles/index.ts
index ed4e012c65104..9b4e07262f724 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -6110,18 +6110,33 @@ const dynamicStyles = (theme: ThemeColors) =>
fontSize: variables.fontSizeLabel,
}) satisfies TextStyle,
- tabBackground: (hovered: boolean, isFocused: boolean, background: string | Animated.AnimatedInterpolation) => ({
- backgroundColor: hovered && !isFocused ? theme.highlightBG : (background as string),
- }),
+ tabBackground: (hovered: boolean, isFocused: boolean, isDisabled: boolean, background: string | Animated.AnimatedInterpolation) => {
+ if (isDisabled) {
+ return {backgroundColor: undefined};
+ }
+
+ return {
+ backgroundColor: hovered && !isFocused ? theme.highlightBG : (background as string),
+ };
+ },
tabOpacity: (
+ isDisabled: boolean,
hovered: boolean,
isFocused: boolean,
activeOpacityValue: number | Animated.AnimatedInterpolation,
inactiveOpacityValue: number | Animated.AnimatedInterpolation,
- ) => ({
- opacity: hovered && !isFocused ? inactiveOpacityValue : activeOpacityValue,
- }),
+ ) => {
+ if (isDisabled) {
+ return {
+ opacity: activeOpacityValue,
+ };
+ }
+
+ return {
+ opacity: hovered && !isFocused ? inactiveOpacityValue : activeOpacityValue,
+ };
+ },
overscrollSpacer: (backgroundColor: string, height: number) =>
({
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 9dee05b230e5f..22236a7ba742a 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -191,6 +191,7 @@ export default {
tabSelectorButtonHeight: 40,
tabSelectorButtonPadding: 12,
tabSelectorScrollMarginInline: 20,
+ tabSelectorMaxTabLabelWidth: 256,
lhnLogoWidth: 95.09,
lhnLogoHeight: 22.33,
signInLogoWidthLargeScreenPill: 162,
@@ -327,9 +328,9 @@ export default {
inlineImagePreviewMinSize: 64,
inlineImagePreviewMaxSize: 148,
- minimalTopBarOffset: -124,
+ minimalTopBarOffset: -176,
searchHeaderDefaultOffset: 0,
- searchListContentMarginTop: 124,
+ searchListContentMarginTop: 176,
searchTopBarZIndex: 9,
searchTopBarHeight: 52,
searchRouterInputMargin: 52,
diff --git a/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts b/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts
new file mode 100644
index 0000000000000..7bb858ea0185f
--- /dev/null
+++ b/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts
@@ -0,0 +1,424 @@
+import {CommonActions} from '@react-navigation/native';
+import type {NavigationRoute, ParamListBase, PartialState, Router, RouterConfigOptions, StackNavigationState} from '@react-navigation/native';
+import addPushParamsRouterExtension from '@libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension';
+import type {CustomHistoryEntry, PushParamsRouterAction} from '@libs/Navigation/AppNavigator/routerExtensions/types';
+import type {PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types';
+import CONST from '@src/CONST';
+
+type TestState = StackNavigationState & {history?: CustomHistoryEntry[]};
+
+function makeRoute(name: string, key: string, params?: Record): NavigationRoute {
+ return {key, name, params} as NavigationRoute;
+}
+
+function makeState(routes: Array>, overrides?: Partial): TestState {
+ return {
+ key: 'stack-test',
+ index: routes.length - 1,
+ routeNames: routes.map((r) => r.name),
+ routes,
+ type: 'stack',
+ stale: false as const,
+ preloadedRoutes: [],
+ ...overrides,
+ };
+}
+
+const CONFIG_OPTIONS: RouterConfigOptions = {
+ routeNames: ['ScreenA', 'ScreenB'],
+ routeParamList: {},
+ routeGetIdList: {},
+};
+
+function createMockRouterFactory(actionHandler?: (state: TestState, action: PushParamsRouterAction) => TestState | null) {
+ const mockRouterFactory = jest.fn((routerOptions: PlatformStackRouterOptions) => {
+ const baseRouter: Router = {
+ type: 'stack',
+
+ getInitialState(configOptions: RouterConfigOptions): TestState {
+ const route = makeRoute(configOptions.routeNames.at(0) ?? routerOptions.initialRouteName ?? 'Screen', 'initial-key-0');
+ return makeState([route]);
+ },
+
+ getRehydratedState(partialState: PartialState): TestState {
+ const routes = partialState.routes.map((r) => ({
+ key: r.key ?? `${r.name}-rehydrated`,
+ name: r.name,
+ params: r.params,
+ })) as Array>;
+ return makeState(routes, {
+ history: partialState.history as CustomHistoryEntry[] | undefined,
+ });
+ },
+
+ getStateForRouteNamesChange(state: TestState): TestState {
+ return state;
+ },
+
+ getStateForRouteFocus(state: TestState): TestState {
+ return state;
+ },
+
+ getStateForAction(state: TestState, action: PushParamsRouterAction): TestState | null {
+ if (actionHandler) {
+ return actionHandler(state, action);
+ }
+
+ if (action.type === 'SET_PARAMS') {
+ const routes = [...state.routes];
+ const focused = routes.at(state.index);
+ if (!focused) {
+ return state;
+ }
+ routes[state.index] = {
+ ...focused,
+ params: {...(focused.params as Record), ...(action.payload as {params?: Record}).params},
+ } as NavigationRoute;
+ return makeState(routes, {history: state.history});
+ }
+
+ if (action.type === 'GO_BACK' || action.type === 'POP') {
+ if (state.routes.length <= 1) {
+ return null;
+ }
+ const routes = state.routes.slice(0, -1);
+ return makeState(routes);
+ }
+
+ if (action.type === 'NAVIGATE') {
+ const payload = action.payload as {name: string; params?: Record};
+ const newRoute = makeRoute(payload.name, `${payload.name}-key-${Date.now()}`, payload.params);
+ return makeState([...state.routes, newRoute]);
+ }
+
+ if (action.type === 'RESET') {
+ const payload = action.payload as {routes: Array<{name: string; key?: string; params?: Record}>; index?: number} | undefined;
+ if (!payload?.routes) {
+ return state;
+ }
+ const routes = payload.routes.map((r) => makeRoute(r.name, r.key ?? `${r.name}-reset`, r.params));
+ return makeState(routes, {index: payload.index ?? routes.length - 1});
+ }
+
+ return state;
+ },
+
+ shouldActionChangeFocus(): boolean {
+ return false;
+ },
+ };
+
+ return baseRouter;
+ });
+
+ return mockRouterFactory;
+}
+
+function asRouteEntry(entry: CustomHistoryEntry): NavigationRoute {
+ return entry as NavigationRoute;
+}
+
+describe('addPushParamsRouterExtension', () => {
+ it('PUSH_PARAMS action sets params on focused route AND appends a snapshot to history', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const route = makeRoute('Search', 'search-1', {q: 'initial'});
+ const state = makeState([route], {
+ history: [{...route}] as CustomHistoryEntry[],
+ });
+
+ const pushParamsAction: PushParamsRouterAction = {
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS,
+ payload: {params: {q: 'updated'}},
+ };
+
+ const newState = enhancedRouter.getStateForAction(state, pushParamsAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect((newState?.routes.at(0)?.params as {q: string}).q).toBe('updated');
+ expect(newState?.history).toHaveLength(2);
+ const lastHistoryEntry = asRouteEntry(newState?.history?.at(1) as CustomHistoryEntry);
+ expect(lastHistoryEntry.key).toBe('search-1');
+ expect((lastHistoryEntry.params as {q: string}).q).toBe('updated');
+ });
+
+ it('GO_BACK with surplus history (same key) reverts params to previous snapshot and pops history', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const route = makeRoute('Search', 'search-1', {q: 'updated'});
+ const state = makeState([route], {
+ history: [
+ {key: 'search-1', name: 'Search', params: {q: 'initial'}} as NavigationRoute,
+ {key: 'search-1', name: 'Search', params: {q: 'updated'}} as NavigationRoute,
+ ],
+ });
+
+ const goBackAction = CommonActions.goBack();
+
+ const newState = enhancedRouter.getStateForAction(state, goBackAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.routes).toHaveLength(1);
+ expect((newState?.routes.at(0)?.params as {q: string}).q).toBe('initial');
+ expect(newState?.history).toHaveLength(1);
+ });
+
+ it('GO_BACK with surplus history (different keys) falls through to normal POP, preserving history for surviving routes', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const routeA = makeRoute('ScreenA', 'a-1', {p: 1});
+ const routeB = makeRoute('ScreenB', 'b-1');
+ const state = makeState([routeA, routeB], {
+ history: [
+ {key: 'a-1', name: 'ScreenA', params: {p: 1}} as NavigationRoute,
+ {key: 'a-1', name: 'ScreenA', params: {p: 2}} as NavigationRoute,
+ {key: 'b-1', name: 'ScreenB'} as NavigationRoute,
+ ],
+ });
+
+ const goBackAction = CommonActions.goBack();
+
+ const newState = enhancedRouter.getStateForAction(state, goBackAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.routes).toHaveLength(1);
+ expect(newState?.routes.at(0)?.key).toBe('a-1');
+
+ const routeHistory = (newState?.history ?? []).filter((e): e is NavigationRoute => typeof e !== 'string');
+ expect(routeHistory.some((e) => e.key === 'a-1')).toBe(true);
+ expect(routeHistory.every((e) => e.key !== 'b-1')).toBe(true);
+ });
+
+ it('GO_BACK without surplus history delegates to underlying router normally', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const routeA = makeRoute('ScreenA', 'a-1');
+ const routeB = makeRoute('ScreenB', 'b-1');
+ const state = makeState([routeA, routeB], {
+ history: [{key: 'a-1', name: 'ScreenA'} as NavigationRoute, {key: 'b-1', name: 'ScreenB'} as NavigationRoute],
+ });
+
+ const goBackAction = CommonActions.goBack();
+
+ const newState = enhancedRouter.getStateForAction(state, goBackAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.routes).toHaveLength(1);
+ expect(newState?.routes.at(0)?.key).toBe('a-1');
+ });
+
+ it('POP behaves the same as GO_BACK for param revert', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const route = makeRoute('Search', 'search-1', {q: 'updated'});
+ const state = makeState([route], {
+ history: [
+ {key: 'search-1', name: 'Search', params: {q: 'initial'}} as NavigationRoute,
+ {key: 'search-1', name: 'Search', params: {q: 'updated'}} as NavigationRoute,
+ ],
+ });
+
+ const popAction: PushParamsRouterAction = {type: 'POP', payload: {count: 1}};
+
+ const newState = enhancedRouter.getStateForAction(state, popAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.routes).toHaveLength(1);
+ expect((newState?.routes.at(0)?.params as {q: string}).q).toBe('initial');
+ expect(newState?.history).toHaveLength(1);
+ });
+
+ it('SET_PARAMS preserves existing history (does not rebuild)', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const route = makeRoute('Search', 'search-1', {q: 'v2'});
+ const originalHistory: CustomHistoryEntry[] = [
+ {key: 'search-1', name: 'Search', params: {q: 'v1'}} as NavigationRoute,
+ {key: 'search-1', name: 'Search', params: {q: 'v2'}} as NavigationRoute,
+ ];
+ const state = makeState([route], {history: originalHistory});
+
+ const setParamsAction: PushParamsRouterAction = {
+ type: CONST.NAVIGATION.ACTION_TYPE.SET_PARAMS,
+ payload: {params: {q: 'v3'}},
+ };
+
+ const newState = enhancedRouter.getStateForAction(state, setParamsAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.history).toHaveLength(2);
+ expect(newState?.history?.at(0)).toEqual(originalHistory.at(0));
+ expect(newState?.history?.at(1)).toEqual(originalHistory.at(1));
+ });
+
+ it('RESET preserves history entries for routes that survive the reset', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const routeA = makeRoute('ScreenA', 'a-1', {p: 1});
+ const routeB = makeRoute('ScreenB', 'b-1');
+ const originalHistory: CustomHistoryEntry[] = [
+ {key: 'a-1', name: 'ScreenA', params: {p: 1}} as NavigationRoute,
+ {key: 'a-1', name: 'ScreenA', params: {p: 2}} as NavigationRoute,
+ {key: 'b-1', name: 'ScreenB'} as NavigationRoute,
+ ];
+ const state = makeState([routeA, routeB], {history: originalHistory});
+
+ const resetAction: PushParamsRouterAction = {
+ type: 'RESET',
+ payload: {
+ routes: [{name: 'ScreenA', key: 'a-1'}],
+ index: 0,
+ },
+ };
+
+ const newState = enhancedRouter.getStateForAction(state, resetAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.routes).toHaveLength(1);
+
+ const routeHistory = (newState?.history ?? []).filter((e): e is NavigationRoute => typeof e !== 'string');
+ expect(routeHistory.every((e) => e.key === 'a-1')).toBe(true);
+ expect(routeHistory).toHaveLength(2);
+ });
+
+ it('Other actions rebuild history from routes via getRehydratedState', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const route = makeRoute('ScreenA', 'a-1');
+ const state = makeState([route], {
+ history: [{key: 'a-1', name: 'ScreenA'} as NavigationRoute],
+ });
+
+ const navigateAction: PushParamsRouterAction = {
+ type: 'NAVIGATE',
+ payload: {name: 'ScreenB'},
+ };
+
+ const newState = enhancedRouter.getStateForAction(state, navigateAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.history).toHaveLength(newState?.routes.length ?? -1);
+ for (const [i, r] of (newState?.routes ?? []).entries()) {
+ const entry = asRouteEntry(newState?.history?.at(i) as CustomHistoryEntry);
+ expect(entry.key).toBe(r.key);
+ }
+ });
+
+ it('returns null when underlying router returns null for unhandled action', () => {
+ const factory = createMockRouterFactory(() => null);
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const route = makeRoute('ScreenA', 'a-1');
+ const state = makeState([route], {
+ history: [{key: 'a-1', name: 'ScreenA'} as NavigationRoute],
+ });
+
+ const navigateAction: PushParamsRouterAction = {
+ type: 'NAVIGATE',
+ payload: {name: 'ScreenC'},
+ };
+
+ const newState = enhancedRouter.getStateForAction(state, navigateAction, CONFIG_OPTIONS);
+ expect(newState).toBeNull();
+ });
+
+ it('PUSH_PARAMS returns state without appending when underlying router returns state without history', () => {
+ const factory = createMockRouterFactory((state, action) => {
+ if (action.type === 'SET_PARAMS') {
+ const routes = [...state.routes];
+ const focused = routes.at(state.index);
+ if (!focused) {
+ return state;
+ }
+ routes[state.index] = {
+ ...focused,
+ params: {...(focused.params as Record), ...(action.payload as {params?: Record}).params},
+ } as NavigationRoute;
+ return {...makeState(routes), history: undefined};
+ }
+ return state;
+ });
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const route = makeRoute('Search', 'search-1', {q: 'initial'});
+ const state = makeState([route], {
+ history: [{...route}] as CustomHistoryEntry[],
+ });
+
+ const pushParamsAction: PushParamsRouterAction = {
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS,
+ payload: {params: {q: 'updated'}},
+ };
+
+ const newState = enhancedRouter.getStateForAction(state, pushParamsAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.history).toBeUndefined();
+ });
+
+ it('GO_BACK with surplus history returns null when single route and underlying router returns null', () => {
+ const factory = createMockRouterFactory(() => null);
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const route = makeRoute('Search', 'search-1', {q: 'v1'});
+ const state = makeState([route], {
+ history: [
+ {key: 'search-1', name: 'Search', params: {q: 'v1'}} as NavigationRoute,
+ {key: 'other-key', name: 'Other', params: {}} as NavigationRoute,
+ ],
+ });
+
+ const goBackAction = CommonActions.goBack();
+ const newState = enhancedRouter.getStateForAction(state, goBackAction, CONFIG_OPTIONS);
+
+ expect(newState).toBeNull();
+ });
+
+ it('multiple PUSH_PARAMS followed by multiple GO_BACKs reverts params step-by-step', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addPushParamsRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const initialRoute = makeRoute('Search', 'search-1', {q: 'v1'});
+ let state: TestState = makeState([initialRoute], {
+ history: [{...initialRoute}] as CustomHistoryEntry[],
+ });
+
+ state = enhancedRouter.getStateForAction(
+ state,
+ {
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS,
+ payload: {params: {q: 'v2'}},
+ },
+ CONFIG_OPTIONS,
+ ) as TestState;
+ expect((state.routes.at(0)?.params as {q: string}).q).toBe('v2');
+ expect(state.history).toHaveLength(2);
+
+ state = enhancedRouter.getStateForAction(
+ state,
+ {
+ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS,
+ payload: {params: {q: 'v3'}},
+ },
+ CONFIG_OPTIONS,
+ ) as TestState;
+ expect((state.routes.at(0)?.params as {q: string}).q).toBe('v3');
+ expect(state.history).toHaveLength(3);
+
+ state = enhancedRouter.getStateForAction(state, CommonActions.goBack(), CONFIG_OPTIONS) as TestState;
+ expect((state.routes.at(0)?.params as {q: string}).q).toBe('v2');
+ expect(state.history).toHaveLength(2);
+
+ state = enhancedRouter.getStateForAction(state, CommonActions.goBack(), CONFIG_OPTIONS) as TestState;
+ expect((state.routes.at(0)?.params as {q: string}).q).toBe('v1');
+ expect(state.history).toHaveLength(1);
+ });
+});
diff --git a/tests/unit/Navigation/routerExtensions/addSidebarRouterExtension.test.ts b/tests/unit/Navigation/routerExtensions/addSidebarRouterExtension.test.ts
new file mode 100644
index 0000000000000..83a3598153225
--- /dev/null
+++ b/tests/unit/Navigation/routerExtensions/addSidebarRouterExtension.test.ts
@@ -0,0 +1,184 @@
+import type {CommonActions, NavigationRoute, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
+import addSidebarRouterExtension from '@libs/Navigation/AppNavigator/routerExtensions/addSidebarRouterExtension';
+import type {CustomHistoryEntry} from '@libs/Navigation/AppNavigator/routerExtensions/types';
+import type {PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types';
+import CONST from '@src/CONST';
+
+type TestState = StackNavigationState & {history?: CustomHistoryEntry[]};
+
+const SIDE_PANEL = CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL;
+
+function makeRoute(name: string, key: string, params?: Record): NavigationRoute {
+ return {key, name, params} as NavigationRoute;
+}
+
+function makeState(routes: Array>, overrides?: Partial): TestState {
+ return {
+ key: 'stack-test',
+ index: routes.length - 1,
+ routeNames: routes.map((r) => r.name),
+ routes,
+ type: 'stack',
+ stale: false as const,
+ preloadedRoutes: [],
+ ...overrides,
+ };
+}
+
+const CONFIG_OPTIONS: RouterConfigOptions = {
+ routeNames: ['ScreenA', 'ScreenB'],
+ routeParamList: {},
+ routeGetIdList: {},
+};
+
+type StackRouterAction = CommonActions.Action | StackActionType;
+
+function createMockRouterFactory(actionHandler?: (state: TestState, action: StackRouterAction) => TestState | null) {
+ const mockRouterFactory = jest.fn((routerOptions: PlatformStackRouterOptions) => {
+ const baseRouter: Router = {
+ type: 'stack',
+
+ getInitialState(configOptions: RouterConfigOptions): TestState {
+ const route = makeRoute(configOptions.routeNames.at(0) ?? routerOptions.initialRouteName ?? 'Screen', 'initial-key-0');
+ return makeState([route]);
+ },
+
+ getRehydratedState(partialState: PartialState): TestState {
+ const routes = partialState.routes.map((r) => ({
+ key: r.key ?? `${r.name}-rehydrated`,
+ name: r.name,
+ params: r.params,
+ })) as Array>;
+ return makeState(routes, {
+ history: partialState.history as CustomHistoryEntry[] | undefined,
+ });
+ },
+
+ getStateForRouteNamesChange(state: TestState): TestState {
+ return state;
+ },
+
+ getStateForRouteFocus(state: TestState): TestState {
+ return state;
+ },
+
+ getStateForAction(state: TestState, action: StackRouterAction): TestState | null {
+ if (actionHandler) {
+ return actionHandler(state, action);
+ }
+
+ if (action.type === 'NAVIGATE') {
+ const payload = action.payload as {name: string; params?: Record};
+ const newRoute = makeRoute(payload.name, `${payload.name}-key-${Date.now()}`, payload.params);
+ return makeState([...state.routes, newRoute]);
+ }
+
+ return state;
+ },
+
+ shouldActionChangeFocus(): boolean {
+ return false;
+ },
+ };
+
+ return baseRouter;
+ });
+
+ return mockRouterFactory;
+}
+
+function asRouteEntry(entry: CustomHistoryEntry): NavigationRoute {
+ return entry as NavigationRoute;
+}
+
+describe('addSidebarRouterExtension', () => {
+ it('getInitialState attaches a history array mirroring routes', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addSidebarRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const state = enhancedRouter.getInitialState(CONFIG_OPTIONS);
+
+ expect(state.history).toBeDefined();
+ expect(state.history).toHaveLength(state.routes.length);
+ for (const [i, route] of state.routes.entries()) {
+ const entry = asRouteEntry(state.history?.at(i) as CustomHistoryEntry);
+ expect(entry.key).toBe(route.key);
+ expect(entry.name).toBe(route.name);
+ }
+ });
+
+ it('getRehydratedState attaches history from routes', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addSidebarRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const partialState = {
+ routes: [
+ {name: 'ScreenA', key: 'a-1', params: {foo: 1}},
+ {name: 'ScreenB', key: 'b-1'},
+ ],
+ stale: true as const,
+ };
+
+ const state = enhancedRouter.getRehydratedState(partialState as PartialState, CONFIG_OPTIONS);
+
+ expect(state.history).toBeDefined();
+ expect(state.history).toHaveLength(2);
+ expect(asRouteEntry(state.history?.at(0) as CustomHistoryEntry).key).toBe('a-1');
+ expect(asRouteEntry(state.history?.at(1) as CustomHistoryEntry).key).toBe('b-1');
+ });
+
+ it('getRehydratedState preserves CUSTOM_HISTORY_ENTRY_SIDE_PANEL when present as last entry in partial state history', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addSidebarRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const partialState = {
+ routes: [{name: 'ScreenA', key: 'a-1'}],
+ stale: true as const,
+ history: [{key: 'a-1', name: 'ScreenA'}, SIDE_PANEL],
+ };
+
+ const state = enhancedRouter.getRehydratedState(partialState as PartialState, CONFIG_OPTIONS);
+
+ expect(state.history).toBeDefined();
+ expect(state.history?.at(-1)).toBe(SIDE_PANEL);
+ const routeEntries = state.history?.filter((e): e is NavigationRoute => typeof e !== 'string') ?? [];
+ expect(routeEntries).toHaveLength(1);
+ });
+
+ it('getRehydratedState does NOT add SIDE_PANEL when it is absent from partial state', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addSidebarRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const partialState = {
+ routes: [{name: 'ScreenA', key: 'a-1'}],
+ stale: true as const,
+ history: [{key: 'a-1', name: 'ScreenA'}],
+ };
+
+ const state = enhancedRouter.getRehydratedState(partialState as PartialState, CONFIG_OPTIONS);
+
+ expect(state.history?.every((e) => e !== SIDE_PANEL)).toBe(true);
+ });
+
+ it('getStateForAction re-attaches history after a generic NAVIGATE action (history rebuilt via getRehydratedState)', () => {
+ const factory = createMockRouterFactory();
+ const enhancedRouter = addSidebarRouterExtension(factory)({} as PlatformStackRouterOptions);
+
+ const initialState = enhancedRouter.getInitialState(CONFIG_OPTIONS);
+
+ const navigateAction: StackRouterAction = {
+ type: 'NAVIGATE',
+ payload: {name: 'ScreenB'},
+ };
+
+ const newState = enhancedRouter.getStateForAction(initialState, navigateAction, CONFIG_OPTIONS);
+
+ expect(newState).not.toBeNull();
+ expect(newState?.history).toBeDefined();
+ expect(newState?.history).toHaveLength(newState?.routes.length ?? -1);
+ for (const [i, r] of (newState?.routes ?? []).entries()) {
+ const entry = asRouteEntry(newState?.history?.at(i) as CustomHistoryEntry);
+ expect(entry.key).toBe(r.key);
+ }
+ });
+});