From cab3fa90df7183ae8c785e6c8d3e18dcdaa57f17 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Thu, 19 Feb 2026 14:19:53 +0100 Subject: [PATCH 01/40] Scrollable tabs on Reports page --- .../Navigators/SearchFullscreenNavigator.tsx | 2 + src/pages/Search/SearchPageNarrow.tsx | 4 +- src/pages/Search/SearchPageTabSelector.tsx | 98 +++++++++++++++++++ src/styles/variables.ts | 2 +- 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 src/pages/Search/SearchPageTabSelector.tsx diff --git a/src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx index 3a222c729d23f..9fefc21f6d596 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx @@ -10,6 +10,7 @@ import FreezeWrapper from '@navigation/AppNavigator/FreezeWrapper'; import type NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; +import ROUTES from '@src/ROUTES'; const loadSearchPage = () => require('@pages/Search/SearchPage').default; @@ -32,6 +33,7 @@ function SearchFullscreenNavigator({route}: PlatformStackScreenProps ROUTES.SEARCH_ROOT.route} initialParams={{q: SearchQueryUtils.buildSearchQueryString()}} options={{animation: Animations.NONE}} /> diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index 94ecdfa219e83..12722a3df6a26 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import Animated, {clamp, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -38,6 +38,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; @@ -210,6 +211,7 @@ function SearchPageNarrow({ styles.searchTopBarZIndexStyle, ]} > + section.menuItems); + const queryMap = new Map(); + const tabItems: TabSelectorBaseItem[] = []; + let activeKey = ''; + + for (const item of flattenedItems) { + const icon = typeof item.icon === 'string' ? expensifyIcons[item.icon] : item.icon; + tabItems.push({ + key: item.key, + icon, + title: translate(item.translationPath), + }); + queryMap.set(item.key, {query: item.searchQuery}); + if (queryJSON && item.similarSearchHash === queryJSON.similarSearchHash) { + activeKey = item.key; + } + } + + if (savedSearches) { + for (const [key, item] of Object.entries(savedSearches)) { + const tabKey = `saved_${key}`; + tabItems.push({ + key: tabKey, + icon: expensifyIcons.Bookmark, + title: item.name ?? item.query ?? '', + }); + queryMap.set(tabKey, {query: item.query ?? '', name: item.name}); + if (queryJSON && Number(key) === queryJSON.hash) { + activeKey = tabKey; + } + } + } + + const handleTabPress = (tabKey: string) => { + const searchData = queryMap.get(tabKey); + if (!searchData) { + return; + } + setSearchContext(false); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchData.query, name: searchData.name})); + }; + + useEffect(() => { + console.log('MOUNT'); + return () => { + console.log('UNMOUNT'); + }; + }, []); + + return ( + + + + ); +} + +export default SearchPageTabSelector; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 0e1f6e5aacf76..b47d611638822 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -317,7 +317,7 @@ export default { minimalTopBarOffset: -124, searchHeaderDefaultOffset: 0, - searchListContentMarginTop: 124, + searchListContentMarginTop: 176, searchTopBarZIndex: 9, searchTopBarHeight: 52, searchRouterInputMargin: 52, From bdbd6604913b60025f82283ecce88c9c8eebf6db Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 24 Feb 2026 20:12:22 +0100 Subject: [PATCH 02/40] Refactor SET_HISTORY_PARAM to PUSH_PARAMS for custom history navigation Rename the custom history action from SET_HISTORY_PARAM to PUSH_PARAMS to better reflect its purpose: pushing a route snapshot onto the history stack when params change, enabling back-navigation through param changes without extra routes. Simplify the history entries to store full route snapshots instead of string keys, and remove the unused getId prop from SearchFullscreenNavigator. --- src/CONST/index.ts | 2 +- .../Navigators/SearchFullscreenNavigator.tsx | 2 - .../addCustomHistoryRouterExtension.ts | 64 ++++++++----------- .../AppNavigator/customHistory/types.ts | 15 +++-- 4 files changed, 35 insertions(+), 48 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index be26435601275..8cefc46d6ba88 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5828,7 +5828,7 @@ const CONST = { 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/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx index 9fefc21f6d596..3a222c729d23f 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx @@ -10,7 +10,6 @@ import FreezeWrapper from '@navigation/AppNavigator/FreezeWrapper'; import type NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; -import ROUTES from '@src/ROUTES'; const loadSearchPage = () => require('@pages/Search/SearchPage').default; @@ -33,7 +32,6 @@ function SearchFullscreenNavigator({route}: PlatformStackScreenProps ROUTES.SEARCH_ROOT.route} initialParams={{q: SearchQueryUtils.buildSearchQueryString()}} options={{animation: Animations.NONE}} /> diff --git a/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts b/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts index d3c26ef4bbd6e..b87487267e975 100644 --- a/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts +++ b/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts @@ -3,30 +3,29 @@ import type {ParamListBase, PartialState, Router, RouterConfigOptions, StackActi 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'; +import type {CustomHistoryEntry, HistoryStackNavigatorAction, PushParamsActionType} from './types'; 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}`; +function isPushParamsAction(action: HistoryStackNavigatorAction): action is PushParamsActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS; } /** * 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. + * 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. + * + * TODO: Remove this custom history extension after upgrading to React Navigation 8, + * which has built-in support for a PUSH_PARAMS-like action. * - * @param originalStackRouter - The original stack router function to be extended + * 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. + * + * @param originalRouter - The original stack router function to be extended * @returns Enhanced router with custom history functionality */ @@ -39,11 +38,11 @@ function addCustomHistoryRouterExtension) => { return { ...state, - history: state.routes.map((route) => route.key), + history: state.routes.map((route) => ({...route})) as CustomHistoryEntry[], }; }; - // Override methods to enhance state with history + // Override router methods to attach a history array (route snapshots) alongside routes. const getInitialState = (configOptions: RouterConfigOptions) => { const state = router.getInitialState(configOptions); return enhanceStateWithHistory(state); @@ -55,12 +54,12 @@ function addCustomHistoryRouterExtension { - // 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}); + if (isPushParamsAction(action)) { + const setParamsAction = CommonActions.setParams(action.payload.params); 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]}; - } + const lastRoute = stateWithUpdatedParams.routes.at(-1); - // 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)}; + if (lastRoute) { + return {...stateWithUpdatedParams, history: [...stateWithUpdatedParams.history, lastRoute]}; } - // Else, do not change history. return stateWithUpdatedParams; } const newState = router.getStateForAction(state, action, configOptions); - // If the action was not handled, return null. + // Action was not handled by the underlying router. if (!newState) { return null; } - // If the action was a setParams action, we need to preserve the history. + // SET_PARAMS should not alter the history stack — keep the existing history as-is. if (isSetParamsAction(action) && state.history) { return { ...newState, @@ -116,9 +105,8 @@ function addCustomHistoryRouterExtension; }; }; -type SetHistoryParamActionType = HistoryStackNavigatorAction & { - type: typeof CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM; +type PushParamsActionType = HistoryStackNavigatorAction & { + type: typeof CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS; }; -export type {HistoryStackNavigatorAction, HistoryStackNavigatorActionType, SetHistoryParamActionType}; +type CustomHistoryEntry = NavigationRoute | string; + +export type {HistoryStackNavigatorAction, HistoryStackNavigatorActionType, PushParamsActionType, CustomHistoryEntry}; From e5781d5a9c9ca57bbd3ac3fa88bda16517bf0f7c Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 24 Feb 2026 20:14:53 +0100 Subject: [PATCH 03/40] Use PUSH_PARAMS navigation action in SearchPageTabSelector --- src/pages/Search/SearchPageTabSelector.tsx | 25 +++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/pages/Search/SearchPageTabSelector.tsx b/src/pages/Search/SearchPageTabSelector.tsx index 7b039b4c0013f..741111d7a677a 100644 --- a/src/pages/Search/SearchPageTabSelector.tsx +++ b/src/pages/Search/SearchPageTabSelector.tsx @@ -1,16 +1,16 @@ -import React, {useEffect} from 'react'; +import {useNavigation} from '@react-navigation/native'; +import React from 'react'; import type {SearchQueryJSON} from '@components/Search/types'; -import type {TabSelectorBaseItem} from '@components/TabSelector/types'; import ScrollableTabSelectorBase from '@components/TabSelector/ScrollableTabSelector/ScrollableTabSelectorBase'; import ScrollableTabSelectorContextProvider from '@components/TabSelector/ScrollableTabSelector/ScrollableTabSelectorContext'; +import type {TabSelectorBaseItem} from '@components/TabSelector/types'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; import {setSearchContext} from '@libs/actions/Search'; -import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; type SearchPageTabSelectorProps = { queryJSON?: SearchQueryJSON; @@ -18,8 +18,9 @@ type SearchPageTabSelectorProps = { function SearchPageTabSelector({queryJSON}: SearchPageTabSelectorProps) { const {translate} = useLocalize(); + const navigation = useNavigation(); const {typeMenuSections} = useSearchTypeMenuSections(); - const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {canBeMissing: true}); + const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'Receipt', 'ChatBubbles', @@ -74,16 +75,14 @@ function SearchPageTabSelector({queryJSON}: SearchPageTabSelectorProps) { return; } setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchData.query, name: searchData.name})); + navigation.dispatch({ + type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS, + payload: { + params: {q: searchData.query, name: searchData.name}, + }, + }); }; - useEffect(() => { - console.log('MOUNT'); - return () => { - console.log('UNMOUNT'); - }; - }, []); - return ( Date: Tue, 24 Feb 2026 20:17:32 +0100 Subject: [PATCH 04/40] Remove unnecessary affectedTabs check from TabSelector background and opacity --- src/components/TabSelector/getBackground.ts | 2 +- src/components/TabSelector/getOpacity.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TabSelector/getBackground.ts b/src/components/TabSelector/getBackground.ts index 5d19e2f42ac8e..81484e2ed4d14 100644 --- a/src/components/TabSelector/getBackground.ts +++ b/src/components/TabSelector/getBackground.ts @@ -15,7 +15,7 @@ function getBackgroundColor({routesLength, tabIndex, affectedTabs, theme, positi }) as unknown as Animated.AnimatedInterpolation; } - return affectedTabs.includes(tabIndex) && isActive ? theme.border : theme.appBG; + return isActive ? theme.border : theme.appBG; } return theme.border; diff --git a/src/components/TabSelector/getOpacity.ts b/src/components/TabSelector/getOpacity.ts index 5bfaca8e96572..39b5483e52058 100644 --- a/src/components/TabSelector/getOpacity.ts +++ b/src/components/TabSelector/getOpacity.ts @@ -14,7 +14,7 @@ function getOpacity({routesLength, tabIndex, active, affectedTabs, position, isA }); } - return affectedTabs.includes(tabIndex) && isActive ? activeValue : inactiveValue; + return isActive ? activeValue : inactiveValue; } return activeValue; From 9814fbde0492e4669b7cd3499ce573dd3a050605 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 2 Mar 2026 21:07:37 +0100 Subject: [PATCH 05/40] Fix goBack with custom history on android --- .../addCustomHistoryRouterExtension.ts | 88 ++++++++++++++++--- src/libs/Navigation/types.ts | 1 + 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts b/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts index b87487267e975..d64a1780b9928 100644 --- a/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts +++ b/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts @@ -1,10 +1,15 @@ -import {CommonActions, findFocusedRoute} from '@react-navigation/native'; -import type {ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native'; +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 {SetParamsAction} from '@libs/Navigation/types'; +import type {GoBackAction, SetParamsAction} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import type {CustomHistoryEntry, HistoryStackNavigatorAction, PushParamsActionType} from './types'; +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: HistoryStackNavigatorAction): action is SetParamsAction { return action.type === CONST.NAVIGATION.ACTION_TYPE.SET_PARAMS; } @@ -13,6 +18,14 @@ function isPushParamsAction(action: HistoryStackNavigatorAction): action is Push return action.type === CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS; } +function isGoBackAction(action: HistoryStackNavigatorAction): action is GoBackAction { + return action.type === 'GO_BACK'; +} + +function isPopAction(action: HistoryStackNavigatorAction): boolean { + return action.type === 'POP'; +} + /** * Higher-order function that extends the React Navigation stack router with custom history functionality. * It maintains a separate history stack of route snapshots that can diverge from the routes array, @@ -52,13 +65,6 @@ function addCustomHistoryRouterExtension 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); // Action was not handled by the underlying router. @@ -105,6 +160,19 @@ function addCustomHistoryRouterExtension 0) { + return { + ...newState, + history: preservedHistory, + }; + } + } + // 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. return getRehydratedState(newState, configOptions); diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e7f8d4c7eb974..4debaaf0c9159 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -3160,6 +3160,7 @@ export type { FeatureTrainingNavigatorParamList, FlagCommentNavigatorParamList, FullScreenName, + GoBackAction, MissingPersonalDetailsParamList, MoneyRequestNavigatorParamList, NavigationPartialRoute, From 24f29fb327b0b698218be44fe2d346f2f690b0e9 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 3 Mar 2026 11:07:35 +0100 Subject: [PATCH 06/40] Refactor router extensions functionality --- .../createRootStackNavigator/index.tsx | 3 +- .../createSearchFullscreenNavigator/index.tsx | 3 +- .../AppNavigator/customHistory/index.ts | 2 - .../addPushParamsRouterExtension.ts} | 36 +- .../addSidebarRouterExtension.ts | 59 +++ .../AppNavigator/routerExtensions/index.ts | 2 + .../types.ts | 0 .../AppNavigator/routerExtensions/utils.ts | 12 + .../index.native.tsx | 3 +- .../index.tsx | 3 +- .../addPushParamsRouterExtension.test.ts | 424 ++++++++++++++++++ .../addSidebarRouterExtension.test.ts | 182 ++++++++ 12 files changed, 696 insertions(+), 33 deletions(-) delete mode 100644 src/libs/Navigation/AppNavigator/customHistory/index.ts rename src/libs/Navigation/AppNavigator/{customHistory/addCustomHistoryRouterExtension.ts => routerExtensions/addPushParamsRouterExtension.ts} (85%) create mode 100644 src/libs/Navigation/AppNavigator/routerExtensions/addSidebarRouterExtension.ts create mode 100644 src/libs/Navigation/AppNavigator/routerExtensions/index.ts rename src/libs/Navigation/AppNavigator/{customHistory => routerExtensions}/types.ts (100%) create mode 100644 src/libs/Navigation/AppNavigator/routerExtensions/utils.ts create mode 100644 tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts create mode 100644 tests/unit/Navigation/routerExtensions/addSidebarRouterExtension.test.ts 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/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/addCustomHistoryRouterExtension.ts b/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts similarity index 85% rename from src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts rename to src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts index d64a1780b9928..11d39c2d3c3d1 100644 --- a/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts +++ b/src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts @@ -4,6 +4,7 @@ import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformS import type {GoBackAction, SetParamsAction} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import type {CustomHistoryEntry, HistoryStackNavigatorAction, PushParamsActionType} from './types'; +import {enhanceStateWithHistory} from './utils'; function preserveHistoryForRoutes(oldHistory: CustomHistoryEntry[], routes: Array<{key?: string}>): CustomHistoryEntry[] { const remainingKeys = new Set(routes.map((r) => r.key)); @@ -27,35 +28,29 @@ function isPopAction(action: HistoryStackNavigatorAction): boolean { } /** - * Higher-order function that extends the React Navigation stack router with custom history functionality. + * 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. - * - * @param originalRouter - The original stack router function to be extended - * @returns Enhanced router with custom history functionality */ - -function addCustomHistoryRouterExtension( +function addPushParamsRouterExtension( originalRouter: PlatformStackRouterFactory, ) { return (options: RouterOptions): Router, HistoryStackNavigatorAction> => { const router = originalRouter(options); - const enhanceStateWithHistory = (state: PlatformStackNavigationState) => { - return { - ...state, - history: state.routes.map((route) => ({...route})) as CustomHistoryEntry[], - }; - }; - - // Override router methods to attach a history array (route snapshots) alongside routes. const getInitialState = (configOptions: RouterConfigOptions) => { const state = router.getInitialState(configOptions); return enhanceStateWithHistory(state); @@ -63,15 +58,7 @@ function addCustomHistoryRouterExtension>, configOptions: RouterConfigOptions) => { const state = router.getRehydratedState(partialState, configOptions); - const stateWithInitialHistory = enhanceStateWithHistory(state); - - // Preserve the side panel custom history entry if it was present in the partial 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; + return enhanceStateWithHistory(state); }; const getStateForAction = ( @@ -147,7 +134,6 @@ function addCustomHistoryRouterExtension(originalRouter: PlatformStackRouterFactory) { + return (options: RouterOptions): Router, HistoryStackNavigatorAction> => { + 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 | HistoryStackNavigatorAction, + 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/customHistory/types.ts b/src/libs/Navigation/AppNavigator/routerExtensions/types.ts similarity index 100% rename from src/libs/Navigation/AppNavigator/customHistory/types.ts rename to src/libs/Navigation/AppNavigator/routerExtensions/types.ts diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/utils.ts b/src/libs/Navigation/AppNavigator/routerExtensions/utils.ts new file mode 100644 index 0000000000000..e5a34c08616d5 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/routerExtensions/utils.ts @@ -0,0 +1,12 @@ +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[], + }; +} + +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 730e269d507af..14bee962337f4 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, @@ -18,7 +17,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/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts b/tests/unit/Navigation/routerExtensions/addPushParamsRouterExtension.test.ts new file mode 100644 index 0000000000000..5e75bfa7acc2f --- /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, HistoryStackNavigatorAction} 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: HistoryStackNavigatorAction) => 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: HistoryStackNavigatorAction): 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: HistoryStackNavigatorAction = { + 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: HistoryStackNavigatorAction = {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: HistoryStackNavigatorAction = { + 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: HistoryStackNavigatorAction = { + 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: HistoryStackNavigatorAction = { + 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: HistoryStackNavigatorAction = { + 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: HistoryStackNavigatorAction = { + 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..4e89ce916e37a --- /dev/null +++ b/tests/unit/Navigation/routerExtensions/addSidebarRouterExtension.test.ts @@ -0,0 +1,182 @@ +import type {NavigationRoute, ParamListBase, PartialState, Router, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; +import addSidebarRouterExtension from '@libs/Navigation/AppNavigator/routerExtensions/addSidebarRouterExtension'; +import type {CustomHistoryEntry, HistoryStackNavigatorAction} 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: {}, +}; + +function createMockRouterFactory(actionHandler?: (state: TestState, action: HistoryStackNavigatorAction) => 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: HistoryStackNavigatorAction): 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: HistoryStackNavigatorAction = { + 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); + } + }); +}); From bd2122dd88781e48f9d5956d9a99e20bd862aadb Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 3 Mar 2026 13:14:46 +0100 Subject: [PATCH 07/40] Fix not scrolling to tabs that are not in view when user navigates back --- .../ScrollableTabSelectorContext.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/TabSelector/ScrollableTabSelector/ScrollableTabSelectorContext.tsx b/src/components/TabSelector/ScrollableTabSelector/ScrollableTabSelectorContext.tsx index d71a6892fdb59..15aa797ed6c3c 100644 --- a/src/components/TabSelector/ScrollableTabSelector/ScrollableTabSelectorContext.tsx +++ b/src/components/TabSelector/ScrollableTabSelector/ScrollableTabSelectorContext.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 ScrollableTabSelectorContextProvider({children, activeTabKey}: Scrollab const containerRef = useRef(null); const containerLayoutRef = useRef<{x: number; width: number}>({x: 0, width: 0}); const tabsRef = useRef>({}); + const lastPressedTab = useRef(''); const onContainerLayout = (event: LayoutChangeEvent) => { const width = event.nativeEvent.layout.width; @@ -68,11 +69,31 @@ function ScrollableTabSelectorContextProvider({children, activeTabKey}: Scrollab return; } + lastPressedTab.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 (activeTabKey === lastPressedTab.current) { + return; + } + + 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 = {containerRef, registerTab, onTabLayout, onContainerLayout, onContainerScroll, scrollToTab}; From 0434e91467563fb554044d0cab9060702a2540b4 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 3 Mar 2026 15:09:27 +0100 Subject: [PATCH 08/40] Delete SearchTypeMenuPopover and correct savedSearches processing --- .../SearchPageHeaderInput.tsx | 16 ------ .../SearchTypeMenuPopover.tsx | 54 ------------------- src/pages/Search/SearchPageNarrow.tsx | 2 +- src/pages/Search/SearchPageTabSelector.tsx | 9 ++-- 4 files changed, 5 insertions(+), 76 deletions(-) delete mode 100644 src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx index d95a4c2bc6985..60b8f75bb46dd 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx @@ -41,7 +41,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; @@ -55,7 +54,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 }, []); @@ -385,11 +374,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 ( - <> -