From eda27a9c170d632d5c273bd47bdbf935440352cc Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 3 Feb 2026 12:56:26 +0100 Subject: [PATCH 01/12] add createDynamicRoute function --- .../Navigation/helpers/createDynamicRoute.ts | 26 +++++++++++++++++++ .../helpers/isDynamicRouteSuffix.ts | 12 +++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/libs/Navigation/helpers/createDynamicRoute.ts create mode 100644 src/libs/Navigation/helpers/isDynamicRouteSuffix.ts diff --git a/src/libs/Navigation/helpers/createDynamicRoute.ts b/src/libs/Navigation/helpers/createDynamicRoute.ts new file mode 100644 index 0000000000000..37b8bd44f3524 --- /dev/null +++ b/src/libs/Navigation/helpers/createDynamicRoute.ts @@ -0,0 +1,26 @@ +import Navigation from '@libs/Navigation/Navigation'; +import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; +import isDynamicRouteSuffix from './isDynamicRouteSuffix'; + +const combinePathAndSuffix = (path: string, suffix: string): Route => { + const [basePath, params] = path.split('?'); + let newPath = path.endsWith('/') ? `${basePath}${suffix}` : `${basePath}/${suffix}`; + + if (params) { + newPath += `?${params}`; + } + return newPath as Route; +}; + +/** Adds dynamic route name to the current URL and returns it */ +const createDynamicRoute = (dynamicRouteSuffix: DynamicRouteSuffix): Route => { + if (!isDynamicRouteSuffix(dynamicRouteSuffix)) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`The route name ${dynamicRouteSuffix} is not supported in createDynamicRoute`); + } + + const activeRoute = Navigation.getActiveRoute(); + return combinePathAndSuffix(activeRoute, dynamicRouteSuffix); +}; + +export default createDynamicRoute; diff --git a/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts b/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts new file mode 100644 index 0000000000000..a660d56e049d5 --- /dev/null +++ b/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts @@ -0,0 +1,12 @@ +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {DynamicRouteSuffix} from '@src/ROUTES'; + +/** + * Checks if a suffix matches any dynamic route path in DYNAMIC_ROUTES. + */ +function isDynamicRouteSuffix(suffix: string): suffix is DynamicRouteSuffix { + const dynamicRouteSuffixes: string[] = Object.values(DYNAMIC_ROUTES).map((route) => route.path); + return dynamicRouteSuffixes.includes(suffix); +} + +export default isDynamicRouteSuffix; From 0f0d1e8a309f1f7417fa7a4a5d701fa1c61fc68f Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 3 Feb 2026 17:59:16 +0100 Subject: [PATCH 02/12] optimise isDynamicRouteSuffix function --- src/libs/Navigation/helpers/isDynamicRouteSuffix.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts b/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts index a660d56e049d5..1e3a758bedc8c 100644 --- a/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts +++ b/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts @@ -5,8 +5,7 @@ import type {DynamicRouteSuffix} from '@src/ROUTES'; * Checks if a suffix matches any dynamic route path in DYNAMIC_ROUTES. */ function isDynamicRouteSuffix(suffix: string): suffix is DynamicRouteSuffix { - const dynamicRouteSuffixes: string[] = Object.values(DYNAMIC_ROUTES).map((route) => route.path); - return dynamicRouteSuffixes.includes(suffix); + return Object.values(DYNAMIC_ROUTES).some((route) => route.path === suffix); } export default isDynamicRouteSuffix; From 443ab2db1ae1a77fc806994b898d7a28daa80ecc Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 3 Feb 2026 14:26:28 +0100 Subject: [PATCH 03/12] add intercept and validate dynamic suffixes --- src/ROUTES.ts | 3 +- src/libs/Navigation/NavigationRoot.tsx | 4 +- .../helpers/getAdaptedStateFromPath.ts | 46 +++++++++++-- .../helpers/getLastSuffixFromPath.ts | 21 ++++++ .../helpers/getStateForDynamicRoute.ts | 67 +++++++++++++++++++ .../Navigation/helpers/getStateFromPath.ts | 28 +++++++- src/libs/actions/Welcome/OnboardingFlow.ts | 3 +- 7 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 src/libs/Navigation/helpers/getLastSuffixFromPath.ts create mode 100644 src/libs/Navigation/helpers/getStateForDynamicRoute.ts diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 13f056926a3db..fea0dd60ccb64 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -79,7 +79,8 @@ type DynamicRoutes = Record; */ const DYNAMIC_ROUTES = { VERIFY_ACCOUNT: { - path: 'verify-account', + // The path is intentionally misspelled to avoid conflicts when dynamic routes logic isn't entirely ready + path: 'verify-accountt', entryScreens: [], }, } as const satisfies DynamicRoutes; diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 7f65fb9dc4658..a251aedf62140 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -109,7 +109,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N Navigation.navigate(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute()); }); - return getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); + return getAdaptedStateFromPath(lastVisitedPath); } if (!account || account.isFromPublicDomain) { @@ -132,7 +132,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N if (!isSpecificDeepLink) { Log.info('Restoring last visited path on app startup', false, {lastVisitedPath, initialUrl, path}); - return getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); + return getAdaptedStateFromPath(lastVisitedPath); } } diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index fdae94a668e13..dfbabdad248c5 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -1,21 +1,24 @@ -import type {NavigationState, PartialState, Route} from '@react-navigation/native'; -import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; import pick from 'lodash/pick'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; -import {config} from '@libs/Navigation/linkingConfig/config'; import {RHP_TO_DOMAIN, RHP_TO_SEARCH, RHP_TO_SETTINGS, RHP_TO_SIDEBAR, RHP_TO_WORKSPACE, RHP_TO_WORKSPACES_LIST} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {NavigationPartialRoute, RootNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route as RoutePath} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Report} from '@src/types/onyx'; +import getLastSuffixFromPath from './getLastSuffixFromPath'; import getMatchingNewRoute from './getMatchingNewRoute'; import getParamsFromRoute from './getParamsFromRoute'; import getRedirectedPath from './getRedirectedPath'; +import getStateFromPath from './getStateFromPath'; +import isDynamicRouteSuffix from './isDynamicRouteSuffix'; import {isFullScreenName} from './isNavigatorName'; import normalizePath from './normalizePath'; import replacePathInNestedState from './replacePathInNestedState'; @@ -31,7 +34,7 @@ Onyx.connect({ type GetAdaptedStateReturnType = ReturnType; -type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; +type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; // The function getPathFromState that we are using in some places isn't working correctly without defined index. const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); @@ -64,7 +67,7 @@ function getSearchScreenNameForRoute(route: NavigationPartialRoute): string { function getMatchingFullScreenRoute(route: NavigationPartialRoute) { // Check for backTo param. One screen with different backTo value may need different screens visible under the overlay. if (isRouteWithBackToParam(route)) { - const stateForBackTo = getStateFromPath(route.params.backTo, config); + const stateForBackTo = getStateFromPath(route.params.backTo as RoutePath); // This may happen if the backTo url is invalid. const lastRoute = stateForBackTo?.routes.at(-1); @@ -87,6 +90,37 @@ function getMatchingFullScreenRoute(route: NavigationPartialRoute) { // If not, get the matching full screen route for the back to state. return getMatchingFullScreenRoute(focusedStateForBackToRoute); } + + // Handle dynamic routes: find the appropriate full screen route + const dynamicRouteSuffix = getLastSuffixFromPath(route.path); + if (isDynamicRouteSuffix(dynamicRouteSuffix)) { + // Remove dynamic suffix to get the base path + const pathWithoutDynamicSuffix = route.path?.replace(`/${dynamicRouteSuffix}`, ''); + + // Get navigation state for the base path without dynamic suffix + const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix as RoutePath); + const lastRoute = stateUnderDynamicRoute?.routes.at(-1); + + if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { + return undefined; + } + + const isLastRouteFullScreen = isFullScreenName(lastRoute.name); + + if (isLastRouteFullScreen) { + return lastRoute; + } + + const focusedStateForDynamicRoute = findFocusedRoute(stateUnderDynamicRoute); + + if (!focusedStateForDynamicRoute) { + return undefined; + } + + // Recursively find the matching full screen route for the focused dynamic route + return getMatchingFullScreenRoute(focusedStateForDynamicRoute); + } + const routeNameForLookup = getSearchScreenNameForRoute(route); if (RHP_TO_SEARCH[routeNameForLookup]) { const paramsFromRoute = getParamsFromRoute(RHP_TO_SEARCH[routeNameForLookup]); @@ -278,7 +312,7 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR normalizedPath = '/'; } - const state = getStateFromPath(normalizedPath, options) as PartialState>; + const state = getStateFromPath(normalizedPath as RoutePath) as PartialState>; if (shouldReplacePathInNestedState) { replacePathInNestedState(state, normalizedPath); } diff --git a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts new file mode 100644 index 0000000000000..3094f923de21f --- /dev/null +++ b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts @@ -0,0 +1,21 @@ +/** + * Extracts the last segment from a URL path, removing query parameters and trailing slashes. + * + * @param path - The URL path to extract the suffix from (can be undefined) + * @returns The last segment of the path as a string + */ +function getLastSuffixFromPath(path: string | undefined): string { + const pathWithoutParams = path?.split('?').at(0); + + if (!pathWithoutParams) { + throw new Error('Failed to parse the path, path is empty'); + } + + const pathWithoutTrailingSlash = pathWithoutParams.endsWith('/') ? pathWithoutParams.slice(0, -1) : pathWithoutParams; + + const lastSuffix = pathWithoutTrailingSlash.split('/').pop() ?? ''; + + return lastSuffix; +} + +export default getLastSuffixFromPath; diff --git a/src/libs/Navigation/helpers/getStateForDynamicRoute.ts b/src/libs/Navigation/helpers/getStateForDynamicRoute.ts new file mode 100644 index 0000000000000..60bb53d0e13fb --- /dev/null +++ b/src/libs/Navigation/helpers/getStateForDynamicRoute.ts @@ -0,0 +1,67 @@ +import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {DynamicRouteSuffix} from '@src/ROUTES'; + +type LeafRoute = { + name: string; + path: string; +}; + +type NestedRoute = { + name: string; + state: { + routes: [RouteNode]; + index: 0; + }; +}; + +type RouteNode = LeafRoute | NestedRoute; + +function getRouteNamesForDynamicRoute(dynamicRouteName: DynamicRouteSuffix): string[] | null { + // Search through normalized configs to find matching path and extract navigation hierarchy + // routeNames contains the sequence of screen/navigator names that should be present in the navigation state + for (const [, config] of Object.entries(normalizedConfigs)) { + if (config.path === dynamicRouteName) { + return config.routeNames; + } + } + + return null; +} + +function getStateForDynamicRoute(path: string, dynamicRouteName: keyof typeof DYNAMIC_ROUTES) { + const routeConfig = getRouteNamesForDynamicRoute(DYNAMIC_ROUTES[dynamicRouteName].path); + + if (!routeConfig) { + throw new Error(`No route configuration found for dynamic route '${dynamicRouteName}'`); + } + + // Build navigation state by creating nested structure + const buildNestedState = (routes: string[], currentIndex: number): RouteNode => { + const currentRoute = routes.at(currentIndex); + + // If this is the last route, create leaf node with path + if (currentIndex === routes.length - 1) { + return { + name: currentRoute ?? '', + path, + }; + } + + // Create intermediate node with nested state + return { + name: currentRoute ?? '', + state: { + routes: [buildNestedState(routes, currentIndex + 1)], + index: 0, + }, + }; + }; + + // Start building from the first route + const rootRoute = {routes: [buildNestedState(routeConfig, 0)]}; + + return rootRoute; +} + +export default getStateForDynamicRoute; diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index b08d003e3c132..1e5725617c8e5 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -1,9 +1,14 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; -import {getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import {findFocusedRoute, getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; import {linkingConfig} from '@libs/Navigation/linkingConfig'; import type {Route} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; +import getLastSuffixFromPath from './getLastSuffixFromPath'; import getMatchingNewRoute from './getMatchingNewRoute'; import getRedirectedPath from './getRedirectedPath'; +import getStateForDynamicRoute from './getStateForDynamicRoute'; +import isDynamicRouteSuffix from './isDynamicRouteSuffix'; /** * @param path - The path to parse @@ -14,6 +19,27 @@ function getStateFromPath(path: Route): PartialState { const redirectedPath = getRedirectedPath(normalizedPath); const normalizedPathAfterRedirection = getMatchingNewRoute(redirectedPath) ?? redirectedPath; + const dynamicRouteSuffix = getLastSuffixFromPath(path); + if (isDynamicRouteSuffix(dynamicRouteSuffix)) { + const pathWithoutDynamicSuffix = path.replace(`/${dynamicRouteSuffix}`, ''); + + type DynamicRouteKey = keyof typeof DYNAMIC_ROUTES; + + // Find the dynamic route key that matches the extracted suffix + const dynamicRoute: string = Object.keys(DYNAMIC_ROUTES).find((key) => DYNAMIC_ROUTES[key as DynamicRouteKey].path === dynamicRouteSuffix) ?? ''; + + // Get the currently focused route from the base path to check permissions + const focusedRoute = findFocusedRoute(getStateFromPath(pathWithoutDynamicSuffix as Route) ?? {}); + const entryScreens: Screen[] = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? []; + + // Check if the focused route is allowed to access this dynamic route + if (focusedRoute?.name && entryScreens.includes(focusedRoute.name as Screen)) { + // Generate navigation state for the dynamic route + const verifyAccountState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey); + return verifyAccountState; + } + } + // This function is used in the linkTo function where we want to use default getStateFromPath function. const state = RNGetStateFromPath(normalizedPathAfterRedirection, linkingConfig.config); diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 0a6c01e8adfec..c99144e736004 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -14,6 +14,7 @@ import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import {hasCompletedGuidedSetupFlowSelector} from '@src/selectors/Onboarding'; import type {Locale, Onboarding} from '@src/types/onyx'; @@ -83,7 +84,7 @@ Onyx.connectWithoutView({ */ function startOnboardingFlow(startOnboardingFlowParams: GetOnboardingInitialPathParamsType) { const currentRoute = navigationRef.getCurrentRoute(); - const adaptedState = getAdaptedStateFromPath(getOnboardingInitialPath(startOnboardingFlowParams), linkingConfig.config, false); + const adaptedState = getAdaptedStateFromPath(getOnboardingInitialPath(startOnboardingFlowParams) as Route, linkingConfig.config, false); const focusedRoute = findFocusedRoute(adaptedState as PartialState>); if (focusedRoute?.name === currentRoute?.name) { return; From 0ceeb409179fcba1382243fa08f1b88b4d54f853 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 4 Feb 2026 10:23:31 +0100 Subject: [PATCH 04/12] no need to add misspelled route name, there's no entry screens --- src/ROUTES.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index fea0dd60ccb64..13f056926a3db 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -79,8 +79,7 @@ type DynamicRoutes = Record; */ const DYNAMIC_ROUTES = { VERIFY_ACCOUNT: { - // The path is intentionally misspelled to avoid conflicts when dynamic routes logic isn't entirely ready - path: 'verify-accountt', + path: 'verify-account', entryScreens: [], }, } as const satisfies DynamicRoutes; From 4ea27616c22477e342c3ca7b44f36152a2363416 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 4 Feb 2026 11:18:36 +0100 Subject: [PATCH 05/12] add all neccessary files to create first dynamic page --- src/hooks/useDynamicBackPath.ts | 17 +++++++++ src/libs/AppState/index.ts | 5 +-- .../ModalStackNavigators/index.tsx | 1 + .../syncBrowserHistory/index.web.ts | 5 +-- src/libs/Navigation/Navigation.ts | 5 ++- .../Navigation/helpers/getPathFromState.ts | 37 +++++++++++++++++++ .../settings/DynamicVerifyAccountPage.tsx | 18 +++++++++ 7 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useDynamicBackPath.ts create mode 100644 src/libs/Navigation/helpers/getPathFromState.ts create mode 100644 src/pages/settings/DynamicVerifyAccountPage.tsx diff --git a/src/hooks/useDynamicBackPath.ts b/src/hooks/useDynamicBackPath.ts new file mode 100644 index 0000000000000..aafa57b17662a --- /dev/null +++ b/src/hooks/useDynamicBackPath.ts @@ -0,0 +1,17 @@ +import {useNavigationState} from '@react-navigation/native'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; +import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; + +/** + * Returns the back path for a dynamic route by removing the dynamic suffix from the current URL. + * @param dynamicRouteSuffix The dynamic route suffix to remove from the current path + * @returns The back path without the dynamic route suffix + */ +function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route { + const path = useNavigationState((state) => getPathFromState(state)); + const backPath = path ? (path.replace(`/${dynamicRouteSuffix}`, '') as Route) : ROUTES.HOME; + return backPath; +} + +export default useDynamicBackPath; diff --git a/src/libs/AppState/index.ts b/src/libs/AppState/index.ts index 38e3d3f574d34..ddf8d4a82ac21 100644 --- a/src/libs/AppState/index.ts +++ b/src/libs/AppState/index.ts @@ -1,9 +1,8 @@ -import {getPathFromState} from '@react-navigation/native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import {navigationRef} from '@libs/Navigation/Navigation'; import {isAuthenticating as isAuthenticatingNetworkStore} from '@libs/Network/NetworkStore'; import CONST from '@src/CONST'; @@ -41,7 +40,7 @@ function captureNavigationState(): NavigationStateInfo { return {currentPath: undefined}; } - const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config); + const routeFromState = getPathFromState(navigationRef.getRootState()); return { currentPath: routeFromState || undefined, }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8e34f4043826b..61089f417453f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -353,6 +353,7 @@ const ConsoleModalStackNavigator = createModalStackNavigator({ + [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/DynamicVerifyAccountPage').default, [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../../pages/ShareCodePage').default, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require('../../../../pages/settings/Profile/PronounsPage').default, [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../../pages/settings/Profile/DisplayNamePage').default, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts index 77431c8ca14b1..a650fb00f8c78 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts @@ -1,11 +1,10 @@ import type {ParamListBase, StackNavigationState} from '@react-navigation/native'; -import {getPathFromState} from '@react-navigation/native'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; function syncBrowserHistory(state: StackNavigationState) { // We reset the URL as the browser sets it in a way that doesn't match the navigation state // eslint-disable-next-line no-restricted-globals - history.replaceState({}, '', getPathFromState(state, linkingConfig.config)); + history.replaceState({}, '', getPathFromState(state)); } export default syncBrowserHistory; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 35c74372fcf5a..9f00beae85070 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,6 +1,6 @@ import {findFocusedRoute, getActionFromState} from '@react-navigation/core'; import type {EventArg, NavigationAction, NavigationContainerEventMap, NavigationState, PartialState} from '@react-navigation/native'; -import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; +import {CommonActions, StackActions} from '@react-navigation/native'; import {Str} from 'expensify-common'; // eslint-disable-next-line you-dont-need-lodash-underscore/omit import omit from 'lodash/omit'; @@ -25,6 +25,7 @@ import type {Account, SidePanel} from '@src/types/onyx'; import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; import getSearchTopmostReportParams from './getSearchTopmostReportParams'; import originalCloseRHPFlow from './helpers/closeRHPFlow'; +import getPathFromState from './helpers/getPathFromState'; import getStateFromPath from './helpers/getStateFromPath'; import getTopmostReportParams from './helpers/getTopmostReportParams'; import {isFullScreenName, isOnboardingFlowName, isSplitNavigatorName} from './helpers/isNavigatorName'; @@ -212,7 +213,7 @@ function getActiveRoute(): string { return ''; } - const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config); + const routeFromState = getPathFromState(navigationRef.getRootState()); if (routeFromState) { return routeFromState; diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts new file mode 100644 index 0000000000000..7718dbfdc405d --- /dev/null +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -0,0 +1,37 @@ +import {findFocusedRoute, getPathFromState as RNGetPathFromState} from '@react-navigation/native'; +import type {NavigationState, PartialState} from '@react-navigation/routers'; +import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; + +type State = NavigationState | Omit, 'stale'>; + +/** + * Checks if a screen name is a dynamic route screen + */ +function isDynamicRouteScreen(screenName: Screen): boolean { + for (const {path} of Object.values(DYNAMIC_ROUTES)) { + if (normalizedConfigs[screenName]?.path === path) { + return true; + } + } + return false; +} + +const getPathFromState = (state: State): string => { + const focusedRoute = findFocusedRoute(state); + const screenName = focusedRoute?.name ?? ''; + + // Handle dynamic route screens that require special path that is placed in state + if (isDynamicRouteScreen(screenName as Screen) && focusedRoute?.path) { + return focusedRoute.path; + } + + // For regular routes, use React Navigation's default path generation + const path = RNGetPathFromState(state, linkingConfig.config); + + return path; +}; + +export default getPathFromState; diff --git a/src/pages/settings/DynamicVerifyAccountPage.tsx b/src/pages/settings/DynamicVerifyAccountPage.tsx new file mode 100644 index 0000000000000..d842e3c44c461 --- /dev/null +++ b/src/pages/settings/DynamicVerifyAccountPage.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import VerifyAccountPageBase from './VerifyAccountPageBase'; + +function DynamicVerifyAccountPage() { + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.VERIFY_ACCOUNT.path); + // currently, the default behavior of this component after completing verification is to navigate back + const forwardPath = backPath; + return ( + + ); +} + +export default DynamicVerifyAccountPage; From 1a2c199de5db08dcf3237f5dadafdfcdd1f6c4b3 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 4 Feb 2026 13:36:30 +0100 Subject: [PATCH 06/12] fix test --- tests/navigation/isActiveRouteTests.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/navigation/isActiveRouteTests.tsx b/tests/navigation/isActiveRouteTests.tsx index 0e88faa69b58b..a592eb7f6d333 100644 --- a/tests/navigation/isActiveRouteTests.tsx +++ b/tests/navigation/isActiveRouteTests.tsx @@ -1,5 +1,5 @@ import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'; -import type {getPathFromState as GetPathFromState} from '@react-navigation/native'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import type {Route} from '@src/ROUTES'; @@ -18,14 +18,7 @@ jest.mock('@libs/Navigation/navigationRef', () => { }; }); -jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const actual = jest.requireActual('@react-navigation/native') as {getPathFromState: typeof GetPathFromState}; - return { - ...actual, - getPathFromState: jest.fn(() => '/settings/profile?backTo=settings'), - }; -}); +jest.mock('@libs/Navigation/helpers/getPathFromState', () => jest.fn()); describe('Navigation', () => { afterEach(() => { @@ -39,8 +32,10 @@ describe('Navigation', () => { isReady: jest.Mock; }; + (getPathFromState as jest.Mock).mockReturnValue('/settings/profile?backTo=settings'); + navigationRefMock.current.getCurrentRoute.mockReturnValue({name: 'test'}); - navigationRefMock.getRootState.mockReturnValue({} as ReturnType); + navigationRefMock.getRootState.mockReturnValue({}); navigationRefMock.isReady.mockReturnValue(true); }); From 51e1ef76d52771d3cb542ac2ad7a493e50ffcf59 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 4 Feb 2026 15:56:15 +0100 Subject: [PATCH 07/12] add first dynamic route + page to the app --- src/ROUTES.ts | 3 +-- src/SCREENS.ts | 1 - .../AppNavigator/ModalStackNavigators/index.tsx | 1 - .../Navigation/helpers/getLastSuffixFromPath.ts | 2 +- src/libs/Navigation/helpers/getStateFromPath.ts | 14 ++++++++++---- .../linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts | 1 - src/libs/Navigation/linkingConfig/config.ts | 4 ---- src/libs/Navigation/types.ts | 2 -- src/pages/settings/DynamicVerifyAccountPage.tsx | 10 +++++++--- src/pages/settings/Wallet/VerifyAccountPage.tsx | 14 -------------- src/pages/settings/Wallet/WalletPage/index.tsx | 5 +++-- 11 files changed, 22 insertions(+), 35 deletions(-) delete mode 100644 src/pages/settings/Wallet/VerifyAccountPage.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 13f056926a3db..347a6c11044d3 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -80,7 +80,7 @@ type DynamicRoutes = Record; const DYNAMIC_ROUTES = { VERIFY_ACCOUNT: { path: 'verify-account', - entryScreens: [], + entryScreens: [SCREENS.SETTINGS.WALLET.ROOT], }, } as const satisfies DynamicRoutes; @@ -333,7 +333,6 @@ const ROUTES = { SETTINGS_ABOUT: 'settings/about', SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', - SETTINGS_WALLET_VERIFY_ACCOUNT: `settings/wallet/${VERIFY_ACCOUNT}`, SETTINGS_WALLET_DOMAIN_CARD: { route: 'settings/wallet/card/:cardID?', getRoute: (cardID: string) => `settings/wallet/card/${cardID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 48c9fed435cba..bbf77f5262576 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -178,7 +178,6 @@ const SCREENS = { WALLET: { ROOT: 'Settings_Wallet', - VERIFY_ACCOUNT: 'Settings_Wallet_VerifyAccount', DOMAIN_CARD: 'Settings_Wallet_DomainCard', DOMAIN_CARD_CONFIRM_MAGIC_CODE: 'Settings_Wallet_DomainCard_ConfirmMagicCode', CARD_MISSING_DETAILS: 'Settings_Wallet_Card_MissingDetails', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 61089f417453f..dea04f4b494dd 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -382,7 +382,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/AboutPage/ConsolePage').default, [SCREENS.SETTINGS.SHARE_LOG]: () => require('../../../../pages/settings/AboutPage/ShareLogPage').default, [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default, - [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/VerifyAccountPage').default, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage/index').default, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD_CONFIRM_MAGIC_CODE]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardVerifyAccountPage').default, diff --git a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts index 3094f923de21f..14679670c41ba 100644 --- a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts +++ b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts @@ -8,7 +8,7 @@ function getLastSuffixFromPath(path: string | undefined): string { const pathWithoutParams = path?.split('?').at(0); if (!pathWithoutParams) { - throw new Error('Failed to parse the path, path is empty'); + return ''; } const pathWithoutTrailingSlash = pathWithoutParams.endsWith('/') ? pathWithoutParams.slice(0, -1) : pathWithoutParams; diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index 1e5725617c8e5..98f56e47cfed4 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -1,5 +1,6 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; import {findFocusedRoute, getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import Log from '@libs/Log'; import {linkingConfig} from '@libs/Navigation/linkingConfig'; import type {Route} from '@src/ROUTES'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -33,10 +34,15 @@ function getStateFromPath(path: Route): PartialState { const entryScreens: Screen[] = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? []; // Check if the focused route is allowed to access this dynamic route - if (focusedRoute?.name && entryScreens.includes(focusedRoute.name as Screen)) { - // Generate navigation state for the dynamic route - const verifyAccountState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey); - return verifyAccountState; + if (focusedRoute?.name) { + if (entryScreens.includes(focusedRoute.name as Screen)) { + // Generate navigation state for the dynamic route + const verifyAccountState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey); + return verifyAccountState; + } + + // Log an error to quickly identify and add forgotten screens to the Dynamic Routes configuration + Log.warn(`[DynamicRoute] Focused route ${focusedRoute.name} is not allowed to access dynamic route with suffix ${dynamicRouteSuffix}`); } } diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index e50234509d291..01a1fd6aee6bc 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -37,7 +37,6 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_CARD_MISSING_DETAILS_CONFIRM_MAGIC_CODE.route, exact: true, }, - [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: { - path: ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT, - exact: true, - }, [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: { path: ROUTES.SETTINGS_REPORT_FRAUD.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 02d71cc6fca56..636eb21df1ee1 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -166,7 +166,6 @@ type SettingsNavigatorParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo: Routes; }; - [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: { /** cardID of selected card */ @@ -213,7 +212,6 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: { policyID: string; }; - [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: undefined; [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: undefined; [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS]: undefined; diff --git a/src/pages/settings/DynamicVerifyAccountPage.tsx b/src/pages/settings/DynamicVerifyAccountPage.tsx index d842e3c44c461..0436495c4d7ea 100644 --- a/src/pages/settings/DynamicVerifyAccountPage.tsx +++ b/src/pages/settings/DynamicVerifyAccountPage.tsx @@ -1,12 +1,16 @@ import React from 'react'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import VerifyAccountPageBase from './VerifyAccountPageBase'; function DynamicVerifyAccountPage() { const backPath = useDynamicBackPath(DYNAMIC_ROUTES.VERIFY_ACCOUNT.path); - // currently, the default behavior of this component after completing verification is to navigate back - const forwardPath = backPath; + + let forwardPath = backPath; + if (backPath === ROUTES.SETTINGS_WALLET) { + forwardPath = ROUTES.SETTINGS_ENABLE_PAYMENTS; + } + return ( - ); -} - -export default VerifyAccountPage; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index c065e508a60e5..dffbd14cbcbf6 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -35,6 +35,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {hasDisplayableAssignedCards, maskCardNumber} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getPaymentMethodDescription} from '@libs/PaymentUtils'; import {getDescriptionForPolicyDomainCard, hasEligibleActiveAdminFromWorkspaces} from '@libs/PolicyUtils'; @@ -47,7 +48,7 @@ import {clearWalletError, clearWalletTermsError, deletePaymentCard, getPaymentMe import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {CardPressHandlerParams, PaymentMethodPressHandlerParams} from './types'; @@ -685,7 +686,7 @@ function WalletPage() { } if (!isUserValidated) { - Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.VERIFY_ACCOUNT.path)); return; } Navigation.navigate(ROUTES.SETTINGS_ENABLE_PAYMENTS); From 3546a82b7322eee146bedea93901f2f81293321f Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 4 Feb 2026 16:19:07 +0100 Subject: [PATCH 08/12] add warnings --- src/libs/Navigation/helpers/getLastSuffixFromPath.ts | 3 +++ src/libs/Navigation/helpers/getStateFromPath.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts index 14679670c41ba..89f6c7f087707 100644 --- a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts +++ b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts @@ -1,3 +1,5 @@ +import Log from '@libs/__mocks__/Log'; + /** * Extracts the last segment from a URL path, removing query parameters and trailing slashes. * @@ -8,6 +10,7 @@ function getLastSuffixFromPath(path: string | undefined): string { const pathWithoutParams = path?.split('?').at(0); if (!pathWithoutParams) { + Log.warn('[getLastSuffixFromPath.ts] Provided path is undefined or empty.'); return ''; } diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index 98f56e47cfed4..cd95bab26aafa 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -42,7 +42,7 @@ function getStateFromPath(path: Route): PartialState { } // Log an error to quickly identify and add forgotten screens to the Dynamic Routes configuration - Log.warn(`[DynamicRoute] Focused route ${focusedRoute.name} is not allowed to access dynamic route with suffix ${dynamicRouteSuffix}`); + Log.warn(`[getStateFromPath.ts][DynamicRoute] Focused route ${focusedRoute.name} is not allowed to access dynamic route with suffix ${dynamicRouteSuffix}`); } } From 962c1d5e27ff11590aee451277bef20c677ef791 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 4 Feb 2026 16:21:01 +0100 Subject: [PATCH 09/12] fix Log import path --- src/libs/Navigation/helpers/getLastSuffixFromPath.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts index 89f6c7f087707..4eee9d98b2eae 100644 --- a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts +++ b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts @@ -1,4 +1,4 @@ -import Log from '@libs/__mocks__/Log'; +import Log from '@libs/Log'; /** * Extracts the last segment from a URL path, removing query parameters and trailing slashes. From fc957d22eef2a3bd1666612a167532da446af5ad Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 5 Feb 2026 11:19:45 +0100 Subject: [PATCH 10/12] add another helper function splitPathAndQuery --- src/hooks/useDynamicBackPath.ts | 22 ++++++++++++++++--- .../Navigation/helpers/createDynamicRoute.ts | 9 ++++---- .../helpers/getLastSuffixFromPath.ts | 9 ++++---- .../Navigation/helpers/splitPathAndQuery.ts | 13 +++++++++++ 4 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 src/libs/Navigation/helpers/splitPathAndQuery.ts diff --git a/src/hooks/useDynamicBackPath.ts b/src/hooks/useDynamicBackPath.ts index aafa57b17662a..4054d7deb142c 100644 --- a/src/hooks/useDynamicBackPath.ts +++ b/src/hooks/useDynamicBackPath.ts @@ -1,17 +1,33 @@ import {useNavigationState} from '@react-navigation/native'; import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; +import splitPathAndQuery from '@libs/Navigation/helpers/splitPathAndQuery'; import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; /** * Returns the back path for a dynamic route by removing the dynamic suffix from the current URL. + * Only removes the suffix if it's the last segment of the path (ignoring trailing slashes and query parameters). * @param dynamicRouteSuffix The dynamic route suffix to remove from the current path - * @returns The back path without the dynamic route suffix + * @returns The back path without the dynamic route suffix, or HOME if path is null/undefined */ function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route { const path = useNavigationState((state) => getPathFromState(state)); - const backPath = path ? (path.replace(`/${dynamicRouteSuffix}`, '') as Route) : ROUTES.HOME; - return backPath; + + if (!path) { + return ROUTES.HOME; + } + + const [normalizedPath, query] = splitPathAndQuery(path); + + if (normalizedPath?.endsWith(`/${dynamicRouteSuffix}`)) { + const backPathWithoutQuery = normalizedPath.slice(0, -(dynamicRouteSuffix.length + 1)); + const backPath = `${backPathWithoutQuery}${query ? `?${query}` : ''}`; + + return backPath as Route; + } + + // If suffix is not the last segment, return the original path + return path as Route; } export default useDynamicBackPath; diff --git a/src/libs/Navigation/helpers/createDynamicRoute.ts b/src/libs/Navigation/helpers/createDynamicRoute.ts index 37b8bd44f3524..17b71a29b025e 100644 --- a/src/libs/Navigation/helpers/createDynamicRoute.ts +++ b/src/libs/Navigation/helpers/createDynamicRoute.ts @@ -1,13 +1,14 @@ import Navigation from '@libs/Navigation/Navigation'; import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; import isDynamicRouteSuffix from './isDynamicRouteSuffix'; +import splitPathAndQuery from './splitPathAndQuery'; const combinePathAndSuffix = (path: string, suffix: string): Route => { - const [basePath, params] = path.split('?'); - let newPath = path.endsWith('/') ? `${basePath}${suffix}` : `${basePath}/${suffix}`; + const [normalizedPath, query] = splitPathAndQuery(path); + let newPath = `${normalizedPath}/${suffix}`; - if (params) { - newPath += `?${params}`; + if (query) { + newPath += `?${query}`; } return newPath as Route; }; diff --git a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts index 4eee9d98b2eae..5a07780c858e6 100644 --- a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts +++ b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts @@ -1,4 +1,5 @@ import Log from '@libs/Log'; +import splitPathAndQuery from './splitPathAndQuery'; /** * Extracts the last segment from a URL path, removing query parameters and trailing slashes. @@ -7,16 +8,14 @@ import Log from '@libs/Log'; * @returns The last segment of the path as a string */ function getLastSuffixFromPath(path: string | undefined): string { - const pathWithoutParams = path?.split('?').at(0); + const [normalizedPath] = splitPathAndQuery(path ?? ''); - if (!pathWithoutParams) { + if (!normalizedPath) { Log.warn('[getLastSuffixFromPath.ts] Provided path is undefined or empty.'); return ''; } - const pathWithoutTrailingSlash = pathWithoutParams.endsWith('/') ? pathWithoutParams.slice(0, -1) : pathWithoutParams; - - const lastSuffix = pathWithoutTrailingSlash.split('/').pop() ?? ''; + const lastSuffix = normalizedPath.split('/').pop() ?? ''; return lastSuffix; } diff --git a/src/libs/Navigation/helpers/splitPathAndQuery.ts b/src/libs/Navigation/helpers/splitPathAndQuery.ts new file mode 100644 index 0000000000000..9c87238b83915 --- /dev/null +++ b/src/libs/Navigation/helpers/splitPathAndQuery.ts @@ -0,0 +1,13 @@ +/** + * Splits a full path into its path and query components. + * @param fullPath - The full URL path (e.g., '/settings/wallet?param=value') + * @returns A tuple where the first element is the path without trailing slash + * and the second element is the query string (if any). + */ +function splitPathAndQuery(fullPath: string | undefined): [string | undefined, string | undefined] { + const [path, query] = fullPath?.split('?') ?? [undefined, undefined]; + const normalizedPath = path?.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path; + return [normalizedPath, query]; +} + +export default splitPathAndQuery; From e1c6fa7bfa41eca8122ca797f8d288311247fecd Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 6 Feb 2026 15:28:00 +0100 Subject: [PATCH 11/12] add a guard to prevent path = undefined --- .../helpers/getAdaptedStateFromPath.ts | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index dfbabdad248c5..b64b96f66c91c 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -92,33 +92,35 @@ function getMatchingFullScreenRoute(route: NavigationPartialRoute) { } // Handle dynamic routes: find the appropriate full screen route - const dynamicRouteSuffix = getLastSuffixFromPath(route.path); - if (isDynamicRouteSuffix(dynamicRouteSuffix)) { - // Remove dynamic suffix to get the base path - const pathWithoutDynamicSuffix = route.path?.replace(`/${dynamicRouteSuffix}`, ''); - - // Get navigation state for the base path without dynamic suffix - const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix as RoutePath); - const lastRoute = stateUnderDynamicRoute?.routes.at(-1); + if (route.path) { + const dynamicRouteSuffix = getLastSuffixFromPath(route.path); + if (isDynamicRouteSuffix(dynamicRouteSuffix)) { + // Remove dynamic suffix to get the base path + const pathWithoutDynamicSuffix = route.path?.replace(`/${dynamicRouteSuffix}`, ''); + + // Get navigation state for the base path without dynamic suffix + const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix as RoutePath); + const lastRoute = stateUnderDynamicRoute?.routes.at(-1); + + if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { + return undefined; + } - if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { - return undefined; - } + const isLastRouteFullScreen = isFullScreenName(lastRoute.name); - const isLastRouteFullScreen = isFullScreenName(lastRoute.name); + if (isLastRouteFullScreen) { + return lastRoute; + } - if (isLastRouteFullScreen) { - return lastRoute; - } + const focusedStateForDynamicRoute = findFocusedRoute(stateUnderDynamicRoute); - const focusedStateForDynamicRoute = findFocusedRoute(stateUnderDynamicRoute); + if (!focusedStateForDynamicRoute) { + return undefined; + } - if (!focusedStateForDynamicRoute) { - return undefined; + // Recursively find the matching full screen route for the focused dynamic route + return getMatchingFullScreenRoute(focusedStateForDynamicRoute); } - - // Recursively find the matching full screen route for the focused dynamic route - return getMatchingFullScreenRoute(focusedStateForDynamicRoute); } const routeNameForLookup = getSearchScreenNameForRoute(route); From 74175863fa05acf9db306f00ea0857c254295a2a Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 6 Feb 2026 15:48:54 +0100 Subject: [PATCH 12/12] add filename to the error text --- src/libs/Navigation/helpers/getLastSuffixFromPath.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts index 3094f923de21f..1178acbf85395 100644 --- a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts +++ b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts @@ -8,7 +8,7 @@ function getLastSuffixFromPath(path: string | undefined): string { const pathWithoutParams = path?.split('?').at(0); if (!pathWithoutParams) { - throw new Error('Failed to parse the path, path is empty'); + throw new Error('[getLastSuffixFromPath.ts] Failed to parse the path, path is empty'); } const pathWithoutTrailingSlash = pathWithoutParams.endsWith('/') ? pathWithoutParams.slice(0, -1) : pathWithoutParams;