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
121 changes: 110 additions & 11 deletions contributingGuides/NAVIGATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ The navigation in the app is built on top of the `react-navigation` library. To
- [Dynamic routes configuration](#dynamic-routes-configuration)
- [Entry screens (access control)](#entry-screens-access-control)
- [Current limitations (work in progress)](#current-limitations-work-in-progress)
- [Multi-segment dynamic routes](#multi-segment-dynamic-routes)
- [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters)
- [How to add a new dynamic route](#how-to-add-a-new-dynamic-route)
- [Migrating from backTo to dynamic routes](#migrating-from-backto-to-dynamic-routes)
- [How to remove backTo from URL (Legacy)](#how-to-remove-backto-from-url)
Expand Down Expand Up @@ -700,7 +702,6 @@ A dynamic route is a URL suffix (e.g. `verify-account`) that can be appended to
Do not use dynamic routes when:
- Your use case falls under the [current limitations](#current-limitations-work-in-progress):
- You need to stack multiple dynamic route suffixes (e.g. `/a/verify-account/another-flow`).
- Your suffix includes path or query parameters (e.g. `verify-account/:id` or `verify-account?tab=details`).
- The screen has a single, fixed entry and a fixed back destination. In this case, use a normal static route instead.

### Dynamic routes configuration
Expand Down Expand Up @@ -732,21 +733,119 @@ When adding or extending a dynamic route, list every screen that should be able
### Current limitations (work in progress)

- **Stacking:** Multiple dynamic route suffixes on top of each other (e.g. `/a/verify-account/another-flow`) are not supported. Only one dynamic suffix per path is allowed.
- **Suffix shape:** Suffixes must be a single path segment. Compound suffixes with extra path segments (e.g. `a/b`) are not supported.
- **Parameters:** Suffixes must not include path params (e.g. `a/:reportID`) or query params (e.g. `a?foo=bar`). Use a single literal segment like `verify-account` only.
- **Path parameters:** Suffixes must not include path params (e.g. `a/:reportID`). Query parameters are supported - see [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters).

If you try to use dynamic routes for these cases now, you will either fail to navigate to the page at all or end up on a non-existent page, and the navigation will be broken.

### How to add a new dynamic route
### Multi-segment dynamic routes

Dynamic route suffixes are not limited to a single path segment -
they can span multiple segments separated by `/`.
For example, the suffix `add-bank-account/verify-account` is a valid
multi-segment suffix that combines two segments into one dynamic route.

When the URL is parsed, the matching algorithm
iterates from the longest candidate suffix to the shortest,
so overlapping registrations are resolved deterministically.
For instance, if both `verify-account` and `add-bank-account/verify-account`
are registered, a path ending with `/add-bank-account/verify-account`
will always match the longer, more specific suffix.

### Dynamic routes with query parameters

Dynamic route suffixes can carry query parameters
(e.g. `country?country=US`).
This is useful when a dynamic screen needs initial data passed
through the URL - for example, a country selector that
pre-selects the current country.

#### How to add query parameters to a dynamic route

1. In `DYNAMIC_ROUTES` (in [`src/ROUTES.ts`](../src/ROUTES.ts)), add a `getRoute` function
that returns the suffix with query parameters and a `queryParams`
array listing every parameter key that the suffix may add:

```ts
ADDRESS_COUNTRY: {
path: 'country',
entryScreens: [SCREENS.SETTINGS.PROFILE.ADDRESS, /* ... */],
getRoute: (country = '') => `country${country ? `?country=${country}` : ''}`,
queryParams: ['country'],
},
```

1. Add to `DYNAMIC_ROUTES` in `src/ROUTES.ts`: define `path` and `entryScreens` (screen names that may open this route).
2. Add a screen constant in `src/SCREENS.ts`. The name must start with the `DYNAMIC_` prefix (e.g. `SETTINGS.DYNAMIC_VERIFY_ACCOUNT`) so dynamic screens can be distinguished from static ones.
3. Register in linking config in `src/libs/Navigation/linkingConfig/config.ts`: map the new screen to `DYNAMIC_ROUTES.<KEY>.path`.
4. Implement the page component: Use `createDynamicRoute(DYNAMIC_ROUTES.<KEY>.path)` to navigate to the flow and `useDynamicBackPath(DYNAMIC_ROUTES.<KEY>.path)` to get the back path. Pass these into your base UI (e.g. `VerifyAccountPageBase` with `navigateBackTo` / `navigateForwardTo`).
5. Register the screen in the appropriate modal/stack navigator (e.g. `src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx`).
6. Types: Add the screen to the navigator param list in `src/libs/Navigation/types.ts` (no params for the new screen).
2. Navigate using `createDynamicRoute` with the output of `getRoute`:

```ts
Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.ADDRESS_COUNTRY.getRoute(countryCode)));
// Produces e.g. /settings/profile/address/country?country=US
```

3. In the page component, read query params from `route.params` as usual:

```ts
const currentCountry = route.params?.country ?? '';
```

> [!CAUTION]
> **`queryParams` array is mandatory.**
> When you define a `getRoute` function that adds query parameters
> after `?` in a dynamic route, you **must** also define a `queryParams`
> array containing **every** parameter key that `getRoute` may produce.
> The `queryParams` array is used to strip suffix-specific parameters when navigating back.
> Without it, query parameters will leak into the parent path
> and break back navigation.

> [!CAUTION]
> **Query parameter names must not collide with inherited entry screen parameters.**
> Do not use a query parameter name in a dynamic suffix that
> already exists as a parameter of any entry screen listed
> in `entryScreens`.
> For example, if an entry screen has a `country` query parameter,
> do not add `country` as a query parameter in your dynamic route.
> When both paths carry the same parameter key,
> `createDynamicRoute` will throw an error:
> `Query param "X" exists in both base path and dynamic suffix.`
> `This is not allowed.`
> This guard prevents non-deterministic parameter handling
> and accidental overwriting of inherited parameters.

The Address Country flow is the reference implementation for
dynamic routes with query parameters:
see [`src/components/CountrySelector.tsx`](../src/components/CountrySelector.tsx) (navigation call)
and [`src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx`](../src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx)
(page component).

### How to add a new dynamic route

The Verify Account flow (Wallet → Dynamic Verify Account) is the reference implementation: see `src/pages/settings/DynamicVerifyAccountPage.tsx` and the Wallet entry point in `src/pages/settings/Wallet/WalletPage/index.tsx`.
1. Add to `DYNAMIC_ROUTES` in [`src/ROUTES.ts`](../src/ROUTES.ts): define `path` and
`entryScreens` (screen names that may open this route).
If the suffix needs query parameters, also define `getRoute`
and `queryParams` - see
[Dynamic routes with query parameters](#dynamic-routes-with-query-parameters).
2. Add a screen constant in [`src/SCREENS.ts`](../src/SCREENS.ts).
The name must start with the `DYNAMIC_` prefix
(e.g. `SETTINGS.DYNAMIC_VERIFY_ACCOUNT`) so dynamic screens
can be distinguished from static ones.
3. Register in linking config in
[`src/libs/Navigation/linkingConfig/config.ts`](../src/libs/Navigation/linkingConfig/config.ts):
map the new screen to `DYNAMIC_ROUTES.<KEY>.path`.
4. Implement the page component:
Use `createDynamicRoute(DYNAMIC_ROUTES.<KEY>.path)` to navigate
to the flow and `useDynamicBackPath(DYNAMIC_ROUTES.<KEY>.path)`
to get the back path. Pass these into your base UI
(e.g. `VerifyAccountPageBase` with `navigateBackTo` /
`navigateForwardTo`).
5. Register the screen in the appropriate modal/stack navigator
(e.g. [`src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx`](../src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx)).
6. Types: Add the screen to the navigator param list in
[`src/libs/Navigation/types.ts`](../src/libs/Navigation/types.ts) (no params for the new screen).

The Verify Account flow (Wallet → Dynamic Verify Account) is the
reference implementation:
see [`src/pages/settings/DynamicVerifyAccountPage.tsx`](../src/pages/settings/DynamicVerifyAccountPage.tsx) and the
Wallet entry point in
[`src/pages/settings/Wallet/WalletPage/index.tsx`](../src/pages/settings/Wallet/WalletPage/index.tsx).

### Migrating from backTo to dynamic routes

Expand Down
21 changes: 15 additions & 6 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const VERIFY_ACCOUNT = 'verify-account';
type DynamicRouteConfig = {
path: string;
entryScreens: Screen[];
getRoute?: (...args: never[]) => string;
queryParams?: readonly string[];
};

type DynamicRoutes = Record<string, DynamicRouteConfig>;
Expand Down Expand Up @@ -97,6 +99,19 @@ const DYNAMIC_ROUTES = {
path: 'owner-selector',
entryScreens: [],
},
ADDRESS_COUNTRY: {
path: 'country',
entryScreens: [
SCREENS.SETTINGS.PROFILE.ADDRESS,
SCREENS.WORKSPACE.ADDRESS,
SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS,
SCREENS.DOMAIN_CARD.DOMAIN_CARD_UPDATE_ADDRESS,
SCREENS.TRAVEL.WORKSPACE_ADDRESS,
SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT,
],
getRoute: (country = '') => `country?country=${country}`,
queryParams: ['country'],
},
} as const satisfies DynamicRoutes;

const ROUTES = {
Expand Down Expand Up @@ -523,12 +538,6 @@ const ROUTES = {
SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth',
SETTINGS_PHONE_NUMBER: 'settings/profile/phone',
SETTINGS_ADDRESS: 'settings/profile/address',
SETTINGS_ADDRESS_COUNTRY: {
route: 'settings/profile/address/country',

// eslint-disable-next-line no-restricted-syntax -- Legacy route generation
getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/address/country?country=${country}`, backTo),
},
SETTINGS_ADDRESS_STATE: {
route: 'settings/profile/address/state',

Expand Down
2 changes: 1 addition & 1 deletion src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const SCREENS = {
PHONE_NUMBER: 'Settings_PhoneNumber',
ADDRESS: 'Settings_Address',
AVATAR: 'Settings_Avatar',
ADDRESS_COUNTRY: 'Settings_Address_Country',
DYNAMIC_ADDRESS_COUNTRY: 'Dynamic_Address_Country',
ADDRESS_STATE: 'Settings_Address_State',
},

Expand Down
6 changes: 3 additions & 3 deletions src/components/CountrySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import type {View} from 'react-native';
import useGeographicalStateAndCountryFromRoute from '@hooks/useGeographicalStateAndCountryFromRoute';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import type {Country} from '@src/CONST';
import ROUTES from '@src/ROUTES';
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import MenuItemWithTopDescription from './MenuItemWithTopDescription';

type CountrySelectorProps = {
Expand Down Expand Up @@ -78,9 +79,8 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange = ()
description={translate('common.country')}
errorText={errorText}
onPress={() => {
const activeRoute = Navigation.getActiveRoute();
didOpenCountrySelector.current = true;
Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute));
Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.ADDRESS_COUNTRY.getRoute(countryCode ?? '')));
}}
/>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/WorkspaceConfirmationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {generateDefaultWorkspaceName, generatePolicyID} from '@libs/actions/Poli
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import {addErrorMessage} from '@libs/ErrorUtils';
import getFirstAlphaNumericCharacter from '@libs/getFirstAlphaNumericCharacter';
import createDynamicRoute from '@libs/Navigation/helpers/createDynamicRoute';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
import {isRequiredFulfilled} from '@libs/ValidationUtils';
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useDynamicBackPath.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix';
import splitPathAndQuery from '@libs/Navigation/helpers/dynamicRoutesUtils/splitPathAndQuery';
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/getPathWithoutDynamicSuffix';
import splitPathAndQuery from '@libs/Navigation/helpers/splitPathAndQuery';
import type {State} from '@libs/Navigation/types';
import type {DynamicRouteSuffix, Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
[SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default,
[SCREENS.SETTINGS.PROFILE.PHONE_NUMBER]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/PersonalDetails/PhoneNumberPage').default,
[SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default,
[SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default,
[SCREENS.SETTINGS.PROFILE.DYNAMIC_ADDRESS_COUNTRY]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage').default,
[SCREENS.SETTINGS.PROFILE.ADDRESS_STATE]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default,
[SCREENS.SETTINGS.PROFILE.AVATAR]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/Avatar/AvatarPage').default,
[SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require<ReactComponentModule>('../../../../pages/settings/Profile/Contacts/ContactMethodsPage').default,
Expand Down
35 changes: 0 additions & 35 deletions src/libs/Navigation/helpers/createDynamicRoute.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import type {Route} from '@src/ROUTES';
import isDynamicRouteSuffix from './isDynamicRouteSuffix';
import splitPathAndQuery from './splitPathAndQuery';

/**
* Merges two query strings into one. If both contain the same key,
* the error is thrown.
* @param baseQuery - The query string of the base path
* @param suffixQuery - The query string of the suffix
* @returns The merged query string or an empty string if both are empty
*
* @private - Internal helper. Do not export or use outside this file.
*
* @example
* mergeQueryStrings('foo=bar', 'foo=baz') => '?foo=bar&baz=qux'
* mergeQueryStrings('foo=bar', 'foo=baz') => throws an error
*/
const mergeQueryStrings = (baseQuery = '', suffixQuery = ''): string => {
if (!baseQuery && !suffixQuery) {
return '';
}
const params = new URLSearchParams(baseQuery);
const suffixParams = new URLSearchParams(suffixQuery);
const suffixParamsEntries = suffixParams.entries();
for (const [key, value] of suffixParamsEntries) {
if (params.has(key)) {
throw new Error(`[createDynamicRoute] Query param "${key}" exists in both base path and dynamic suffix. This is not allowed.`);
}
params.set(key, value);
}
const result = params.toString();
return result ? `?${result}` : '';
};

/**
* Combines a base path with a dynamic route suffix, merging their query parameters.
*
* @private - Internal helper. Do not export or use outside this file.
*/
const combinePathAndSuffix = (basePath: string, suffixWithQuery: string): Route => {
const [normalizedBasePath, baseQuery] = splitPathAndQuery(basePath);
const [suffixPath, suffixQuery] = splitPathAndQuery(suffixWithQuery);

if (!normalizedBasePath) {
Log.warn('[createDynamicRoute.ts] Path is undefined or empty, returning suffix only', {basePath, suffixWithQuery});
return suffixWithQuery as Route;
}

const combinedPath = normalizedBasePath === '/' ? `/${suffixPath}` : `${normalizedBasePath}/${suffixPath}`;
const mergedQuery = mergeQueryStrings(baseQuery, suffixQuery);

return `${combinedPath}${mergedQuery}` as Route;
};

/** Adds dynamic route name (with optional query params) to the current URL and returns it */
const createDynamicRoute = (dynamicRouteSuffixWithParams: string): Route => {
const [suffixPath] = splitPathAndQuery(dynamicRouteSuffixWithParams);

if (!suffixPath || !isDynamicRouteSuffix(suffixPath)) {
throw new Error(`The route name ${suffixPath} is not supported in createDynamicRoute`);
}

const activeRoute = Navigation.getActiveRoute();
return combinePathAndSuffix(activeRoute, dynamicRouteSuffixWithParams);
};

export default createDynamicRoute;
Loading
Loading