Skip to content

Commit 6ba9a7d

Browse files
authored
Merge pull request Expensify#82256 from software-mansion-labs/dynamic-routes/fixed-batch-4
2 parents 81afbbc + 12265b2 commit 6ba9a7d

17 files changed

Lines changed: 197 additions & 68 deletions

File tree

src/hooks/useDynamicBackPath.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
2+
import splitPathAndQuery from '@libs/Navigation/helpers/splitPathAndQuery';
3+
import type {State} from '@libs/Navigation/types';
4+
import type {DynamicRouteSuffix, Route} from '@src/ROUTES';
5+
import ROUTES from '@src/ROUTES';
6+
import useRootNavigationState from './useRootNavigationState';
7+
8+
/**
9+
* Returns the back path for a dynamic route by removing the dynamic suffix from the current URL.
10+
* Only removes the suffix if it's the last segment of the path (ignoring trailing slashes and query parameters).
11+
* @param dynamicRouteSuffix The dynamic route suffix to remove from the current path
12+
* @returns The back path without the dynamic route suffix, or HOME if path is null/undefined
13+
*/
14+
function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route {
15+
const path = useRootNavigationState((state) => {
16+
if (!state) {
17+
return undefined;
18+
}
19+
20+
return getPathFromState(state as State);
21+
});
22+
23+
if (!path) {
24+
return ROUTES.HOME;
25+
}
26+
27+
// Remove leading slashes for consistent processing
28+
const pathWithoutLeadingSlash = path.replace(/^\/+/, '');
29+
30+
const [normalizedPath, query] = splitPathAndQuery(pathWithoutLeadingSlash);
31+
32+
if (normalizedPath?.endsWith(`/${dynamicRouteSuffix}`)) {
33+
const backPathWithoutQuery = normalizedPath.slice(0, -(dynamicRouteSuffix.length + 1));
34+
const backPath = `${backPathWithoutQuery}${query ? `?${query}` : ''}`;
35+
36+
return backPath as Route;
37+
}
38+
39+
// If suffix is not the last segment, return the original path
40+
return pathWithoutLeadingSlash as Route;
41+
}
42+
43+
export default useDynamicBackPath;

src/libs/AppState/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import {getPathFromState} from '@react-navigation/native';
21
import type {OnyxEntry} from 'react-native-onyx';
32
import Onyx from 'react-native-onyx';
43
import type {ValueOf} from 'type-fest';
54
import Log from '@libs/Log';
6-
import {linkingConfig} from '@libs/Navigation/linkingConfig';
5+
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
76
import {navigationRef} from '@libs/Navigation/Navigation';
87
import {isAuthenticating as isAuthenticatingNetworkStore} from '@libs/Network/NetworkStore';
98
import CONST from '@src/CONST';
@@ -41,7 +40,7 @@ function captureNavigationState(): NavigationStateInfo {
4140
return {currentPath: undefined};
4241
}
4342

44-
const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
43+
const routeFromState = getPathFromState(navigationRef.getRootState());
4544
return {
4645
currentPath: routeFromState || undefined,
4746
};

src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ const NewTeachersUniteNavigator = createModalStackNavigator<TeachersUniteNavigat
379379
});
380380

381381
const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorParamList>({
382+
[SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/settings/DynamicVerifyAccountPage').default,
382383
[SCREENS.SETTINGS.SHARE_CODE]: () => require<ReactComponentModule>('../../../../pages/ShareCodePage').default,
383384
[SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/PronounsPage').default,
384385
[SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/DisplayNamePage').default,
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type {ParamListBase, StackNavigationState} from '@react-navigation/native';
2-
import {getPathFromState} from '@react-navigation/native';
3-
import {linkingConfig} from '@libs/Navigation/linkingConfig';
2+
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
43

54
function syncBrowserHistory(state: StackNavigationState<ParamListBase>) {
65
// We reset the URL as the browser sets it in a way that doesn't match the navigation state
76
// eslint-disable-next-line no-restricted-globals
8-
history.replaceState({}, '', getPathFromState(state, linkingConfig.config));
7+
history.replaceState({}, '', getPathFromState(state));
98
}
109

1110
export default syncBrowserHistory;

src/libs/Navigation/Navigation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {findFocusedRoute, getActionFromState} from '@react-navigation/core';
22
import type {EventArg, NavigationAction, NavigationContainerEventMap, NavigationState, PartialState} from '@react-navigation/native';
3-
import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native';
3+
import {CommonActions, StackActions} from '@react-navigation/native';
44
import {Str} from 'expensify-common';
55
// eslint-disable-next-line you-dont-need-lodash-underscore/omit
66
import omit from 'lodash/omit';
@@ -25,6 +25,7 @@ import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS';
2525
import type {Account, SidePanel} from '@src/types/onyx';
2626
import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState';
2727
import originalCloseRHPFlow from './helpers/closeRHPFlow';
28+
import getPathFromState from './helpers/getPathFromState';
2829
import getStateFromPath from './helpers/getStateFromPath';
2930
import getTopmostReportParams from './helpers/getTopmostReportParams';
3031
import {isFullScreenName, isOnboardingFlowName, isSplitNavigatorName} from './helpers/isNavigatorName';
@@ -220,7 +221,7 @@ function getActiveRoute(): string {
220221
return '';
221222
}
222223

223-
const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
224+
const routeFromState = getPathFromState(navigationRef.getRootState());
224225

225226
if (routeFromState) {
226227
return routeFromState;

src/libs/Navigation/NavigationRoot.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {NavigationState} from '@react-navigation/native';
2-
import {DarkTheme, DefaultTheme, findFocusedRoute, getPathFromState, NavigationContainer} from '@react-navigation/native';
2+
import {DarkTheme, DefaultTheme, findFocusedRoute, NavigationContainer} from '@react-navigation/native';
33
import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
44
import * as Sentry from '@sentry/react-native';
55
import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
@@ -26,6 +26,7 @@ import ROUTES from '@src/ROUTES';
2626
import AppNavigator from './AppNavigator';
2727
import {cleanPreservedNavigatorStates} from './AppNavigator/createSplitNavigator/usePreserveNavigatorState';
2828
import getAdaptedStateFromPath from './helpers/getAdaptedStateFromPath';
29+
import getPathFromState from './helpers/getPathFromState';
2930
import {isSplitNavigatorName, isWorkspacesTabScreenName} from './helpers/isNavigatorName';
3031
import {saveSettingsTabPathToSessionStorage, saveWorkspacesTabPathToSessionStorage} from './helpers/lastVisitedTabPathUtils';
3132
import {linkingConfig} from './linkingConfig';
@@ -53,7 +54,7 @@ function parseAndLogRoute(state: NavigationState) {
5354
return;
5455
}
5556

56-
const currentPath = getPathFromState(state, linkingConfig.config);
57+
const currentPath = getPathFromState(state);
5758

5859
const focusedRoute = findFocusedRoute(state);
5960

src/libs/Navigation/helpers/createDynamicRoute.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1+
import Log from '@libs/Log';
12
import Navigation from '@libs/Navigation/Navigation';
23
import type {DynamicRouteSuffix, Route} from '@src/ROUTES';
34
import isDynamicRouteSuffix from './isDynamicRouteSuffix';
5+
import splitPathAndQuery from './splitPathAndQuery';
46

57
const combinePathAndSuffix = (path: string, suffix: string): Route => {
6-
const [basePath, params] = path.split('?');
7-
let newPath = basePath.endsWith('/') ? `${basePath}${suffix}` : `${basePath}/${suffix}`;
8+
const [normalizedPath, query] = splitPathAndQuery(path);
89

9-
if (params) {
10-
newPath += `?${params}`;
10+
// This should never happen as the path should always be defined
11+
if (!normalizedPath) {
12+
Log.warn('[createDynamicRoute.ts] Path is undefined or empty, returning suffix only', {path, suffix});
13+
return suffix as Route;
14+
}
15+
16+
let newPath = normalizedPath === '/' ? `/${suffix}` : `${normalizedPath}/${suffix}`;
17+
18+
if (query) {
19+
newPath += `?${query}`;
1120
}
1221
return newPath as Route;
1322
};

src/libs/Navigation/helpers/getAdaptedStateFromPath.ts

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -80,38 +80,6 @@ function getMatchingFullScreenRoute(route: NavigationPartialRoute) {
8080
return getMatchingFullScreenRoute(focusedStateForBackToRoute);
8181
}
8282

83-
// Handle dynamic routes: find the appropriate full screen route
84-
if (route.path) {
85-
const dynamicRouteSuffix = getLastSuffixFromPath(route.path);
86-
if (isDynamicRouteSuffix(dynamicRouteSuffix)) {
87-
// Remove dynamic suffix to get the base path
88-
const pathWithoutDynamicSuffix = route.path?.replace(`/${dynamicRouteSuffix}`, '');
89-
90-
// Get navigation state for the base path without dynamic suffix
91-
const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix as RoutePath);
92-
const lastRoute = stateUnderDynamicRoute?.routes.at(-1);
93-
94-
if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) {
95-
return undefined;
96-
}
97-
98-
const isLastRouteFullScreen = isFullScreenName(lastRoute.name);
99-
100-
if (isLastRouteFullScreen) {
101-
return lastRoute;
102-
}
103-
104-
const focusedStateForDynamicRoute = findFocusedRoute(stateUnderDynamicRoute);
105-
106-
if (!focusedStateForDynamicRoute) {
107-
return undefined;
108-
}
109-
110-
// Recursively find the matching full screen route for the focused dynamic route
111-
return getMatchingFullScreenRoute(focusedStateForDynamicRoute);
112-
}
113-
}
114-
11583
const routeNameForLookup = getSearchScreenNameForRoute(route);
11684
if (RHP_TO_SEARCH[routeNameForLookup]) {
11785
const paramsFromRoute = getParamsFromRoute(RHP_TO_SEARCH[routeNameForLookup]);
@@ -200,6 +168,38 @@ function getMatchingFullScreenRoute(route: NavigationPartialRoute) {
200168
);
201169
}
202170

171+
// Handle dynamic routes: find the appropriate full screen route
172+
if (route.path) {
173+
const dynamicRouteSuffix = getLastSuffixFromPath(route.path);
174+
if (isDynamicRouteSuffix(dynamicRouteSuffix)) {
175+
// Remove dynamic suffix to get the base path
176+
const pathWithoutDynamicSuffix = route.path?.replace(`/${dynamicRouteSuffix}`, '');
177+
178+
// Get navigation state for the base path without dynamic suffix
179+
const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix as RoutePath);
180+
const lastRoute = stateUnderDynamicRoute?.routes.at(-1);
181+
182+
if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) {
183+
return undefined;
184+
}
185+
186+
const isLastRouteFullScreen = isFullScreenName(lastRoute.name);
187+
188+
if (isLastRouteFullScreen) {
189+
return lastRoute;
190+
}
191+
192+
const focusedStateForDynamicRoute = findFocusedRoute(stateUnderDynamicRoute);
193+
194+
if (!focusedStateForDynamicRoute) {
195+
return undefined;
196+
}
197+
198+
// Recursively find the matching full screen route for the focused dynamic route
199+
return getMatchingFullScreenRoute(focusedStateForDynamicRoute);
200+
}
201+
}
202+
203203
return undefined;
204204
}
205205

@@ -307,11 +307,12 @@ function getAdaptedState(state: PartialState<NavigationState<RootNavigatorParamL
307307
* see the NAVIGATION.md documentation.
308308
*
309309
* @param path - The path to generate state from
310-
* @param options - Extra options to fine-tune how to parse the path
311-
* @param shouldReplacePathInNestedState - Whether to replace the path in nested state
310+
* @param options - Extra options kept for react-navigation compatibility
311+
* @param shouldReplacePathInNestedState - Whether to replace the path in nested state (if passing this arg, pass `undefined` for `options`, otherwise omit both)
312312
* @returns The adapted navigation state
313313
* @throws Error if unable to get state from path
314314
*/
315+
// We keep `options` in the signature for `linkingConfig` compatibility with react-navigation.
315316
const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldReplacePathInNestedState = true) => {
316317
let normalizedPath = !path.startsWith('/') ? `/${path}` : path;
317318
normalizedPath = getRedirectedPath(normalizedPath);

src/libs/Navigation/helpers/getLastSuffixFromPath.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Log from '@libs/Log';
2+
13
/**
24
* Extracts the last segment from a URL path, removing query parameters and trailing slashes.
35
*
@@ -8,7 +10,8 @@ function getLastSuffixFromPath(path: string | undefined): string {
810
const pathWithoutParams = path?.split('?').at(0);
911

1012
if (!pathWithoutParams) {
11-
throw new Error('[getLastSuffixFromPath.ts] Failed to parse the path, path is empty');
13+
Log.warn('[getLastSuffixFromPath.ts] Failed to parse the path, path is empty');
14+
return '';
1215
}
1316

1417
const pathWithoutTrailingSlash = pathWithoutParams.endsWith('/') ? pathWithoutParams.slice(0, -1) : pathWithoutParams;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {findFocusedRoute, getPathFromState as RNGetPathFromState} from '@react-navigation/native';
2+
import type {NavigationState, PartialState} from '@react-navigation/routers';
3+
import {linkingConfig} from '@libs/Navigation/linkingConfig';
4+
import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config';
5+
import {DYNAMIC_ROUTES} from '@src/ROUTES';
6+
import type {Screen} from '@src/SCREENS';
7+
8+
type State = NavigationState | Omit<PartialState<NavigationState>, 'stale'>;
9+
10+
const dynamicRouteEntries = Object.values(DYNAMIC_ROUTES);
11+
12+
/**
13+
* Checks if a screen name is a dynamic route screen
14+
*/
15+
function isDynamicRouteScreen(screenName: Screen): boolean {
16+
const screenPath = normalizedConfigs[screenName]?.path;
17+
18+
if (!screenPath) {
19+
return false;
20+
}
21+
22+
for (const {path} of dynamicRouteEntries) {
23+
if (screenPath === path) {
24+
return true;
25+
}
26+
}
27+
return false;
28+
}
29+
30+
const getPathFromState = (state: State): string => {
31+
const focusedRoute = findFocusedRoute(state);
32+
const screenName = focusedRoute?.name ?? '';
33+
34+
// Handle dynamic route screens that require special path that is placed in state
35+
if (isDynamicRouteScreen(screenName as Screen) && focusedRoute?.path) {
36+
return focusedRoute.path;
37+
}
38+
39+
// For regular routes, use React Navigation's default path generation
40+
const path = RNGetPathFromState(state, linkingConfig.config);
41+
42+
return path;
43+
};
44+
45+
export default getPathFromState;

0 commit comments

Comments
 (0)