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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/hooks/useDynamicBackPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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, or HOME if path is null/undefined
*/
function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route {
const path = useNavigationState((state) => getPathFromState(state));

if (!path) {
return ROUTES.HOME;
}

// Remove leading slashes for consistent processing
const pathWithoutLeadingSlash = path.replace(/^\/+/, '');

const [normalizedPath, query] = splitPathAndQuery(pathWithoutLeadingSlash);

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 pathWithoutLeadingSlash as Route;
}

export default useDynamicBackPath;
5 changes: 2 additions & 3 deletions src/libs/AppState/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ const ConsoleModalStackNavigator = createModalStackNavigator<ConsoleNavigatorPar
});

const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorParamList>({
[SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/settings/DynamicVerifyAccountPage').default,
[SCREENS.SETTINGS.SHARE_CODE]: () => require<ReactComponentModule>('../../../../pages/ShareCodePage').default,
[SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/PronounsPage').default,
[SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/DisplayNamePage').default,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ParamListBase>) {
// 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;
5 changes: 3 additions & 2 deletions src/libs/Navigation/Navigation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,6 +26,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';
Expand Down Expand Up @@ -213,7 +214,7 @@ function getActiveRoute(): string {
return '';
}

const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
const routeFromState = getPathFromState(navigationRef.getRootState());

if (routeFromState) {
return routeFromState;
Expand Down
10 changes: 5 additions & 5 deletions src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {NavigationState} from '@react-navigation/native';
import {DarkTheme, DefaultTheme, findFocusedRoute, getPathFromState, NavigationContainer} from '@react-navigation/native';
import {DarkTheme, DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native';
import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding';
import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
import {useOnboardingValues} from '@components/OnyxListItemProvider';
Expand Down Expand Up @@ -29,6 +29,7 @@ import ROUTES from '@src/ROUTES';
import AppNavigator from './AppNavigator';
import {cleanPreservedNavigatorStates} from './AppNavigator/createSplitNavigator/usePreserveNavigatorState';
import getAdaptedStateFromPath from './helpers/getAdaptedStateFromPath';
import getPathFromState from './helpers/getPathFromState';
import {isSplitNavigatorName, isWorkspacesTabScreenName} from './helpers/isNavigatorName';
import {saveSettingsTabPathToSessionStorage, saveWorkspacesTabPathToSessionStorage} from './helpers/lastVisitedTabPathUtils';
import {linkingConfig} from './linkingConfig';
Expand Down Expand Up @@ -56,7 +57,7 @@ function parseAndLogRoute(state: NavigationState) {
return;
}

const currentPath = getPathFromState(state, linkingConfig.config);
const currentPath = getPathFromState(state);

const focusedRoute = findFocusedRoute(state);

Expand Down Expand Up @@ -121,7 +122,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) {
Expand All @@ -148,7 +149,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
onboardingInitialPath,
onboardingValues,
}),
linkingConfig.config,
);
}

Expand All @@ -160,7 +160,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);
}
}

Expand Down
15 changes: 11 additions & 4 deletions src/libs/Navigation/helpers/createDynamicRoute.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
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 = basePath.endsWith('/') ? `${basePath}${suffix}` : `${basePath}/${suffix}`;
const [normalizedPath, query] = splitPathAndQuery(path);

if (params) {
newPath += `?${params}`;
// This should never happen as the path should always be defined
if (!normalizedPath) {
throw new Error('Path is undefined or empty');
}

let newPath = `${normalizedPath}/${suffix}`;

if (query) {
newPath += `?${query}`;
}
return newPath as Route;
};
Expand Down
53 changes: 45 additions & 8 deletions src/libs/Navigation/helpers/getAdaptedStateFromPath.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
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 {getSearchParamFromPath} from '@src/libs/Url';
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';

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 28 in src/libs/Navigation/helpers/getAdaptedStateFromPath.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function

Check warning on line 28 in src/libs/Navigation/helpers/getAdaptedStateFromPath.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -32,7 +35,7 @@

type GetAdaptedStateReturnType = ReturnType<typeof getStateFromPath>;

type GetAdaptedStateFromPath = (...args: [...Parameters<typeof getStateFromPath>, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType;
type GetAdaptedStateFromPath = (...args: [...Parameters<typeof RNGetStateFromPath>, 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<NavigationState> => ({routes, index: routes.length - 1});
Expand Down Expand Up @@ -65,7 +68,7 @@
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that you’re using type assertions like as Route or as RoutePath because we’re calling getStateFromPath with a value typed as Route. However, the getStateFromPath API from React Navigation actually accepts a plain string.

What’s your opinion on this approach? Do you think we should avoid these casts and instead handle the path as a string with proper validation before passing it in?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we should keep the Route type. It makes type assertions explicit and gives developers a hint about which routes are valid for state generation.
I kept it as Route to maintain consistency, since our custom getStateFromPath has accepted Route from the start. I still need to research whether switching to string is strictly better. If it turns out that Route isn't necessary, I suggest making that change in a follow-up PR.


// This may happen if the backTo url is invalid.
const lastRoute = stateForBackTo?.routes.at(-1);
Expand All @@ -88,6 +91,39 @@
// 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
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;
}

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]);
Expand Down Expand Up @@ -268,11 +304,12 @@
* see the NAVIGATION.md documentation.
*
* @param path - The path to generate state from
* @param options - Extra options to fine-tune how to parse the path
* @param shouldReplacePathInNestedState - Whether to replace the path in nested state
* @param options - Extra options kept for react-navigation compatibility
* @param shouldReplacePathInNestedState - Whether to replace the path in nested state (if passing this arg, pass `undefined` for `options`, otherwise omit both)
* @returns The adapted navigation state
* @throws Error if unable to get state from path
*/
// We keep `options` in the signature for `linkingConfig` compatibility with react-navigation.
const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldReplacePathInNestedState = true) => {
let normalizedPath = !path.startsWith('/') ? `/${path}` : path;
normalizedPath = getRedirectedPath(normalizedPath);
Expand All @@ -283,7 +320,7 @@
normalizedPath = '/';
}

const state = getStateFromPath(normalizedPath, options) as PartialState<NavigationState<RootNavigatorParamList>>;
const state = getStateFromPath(normalizedPath as RoutePath) as PartialState<NavigationState<RootNavigatorParamList>>;
if (shouldReplacePathInNestedState) {
replacePathInNestedState(state, normalizedPath);
}
Expand Down
21 changes: 21 additions & 0 deletions src/libs/Navigation/helpers/getLastSuffixFromPath.ts
Original file line number Diff line number Diff line change
@@ -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('[getLastSuffixFromPath.ts] 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;
44 changes: 44 additions & 0 deletions src/libs/Navigation/helpers/getPathFromState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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<PartialState<NavigationState>, 'stale'>;

/**
* Checks if a screen name is a dynamic route screen
*/
function isDynamicRouteScreen(screenName: Screen): boolean {
const dynamicRouteEntries = Object.values(DYNAMIC_ROUTES);
const screenPath = normalizedConfigs[screenName]?.path;

if (!screenPath) {
return false;
}

for (const {path} of dynamicRouteEntries) {
if (screenPath === 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;
Loading
Loading