Skip to content

Commit b538fb3

Browse files
authored
Merge pull request Expensify#87870 from software-mansion-labs/collectioneur/dynamic-routes-backward-compatibility
2 parents b825d0e + cb9b10b commit b538fb3

9 files changed

Lines changed: 135 additions & 30 deletions

File tree

contributingGuides/NAVIGATION.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The navigation in the app is built on top of the `react-navigation` library. To
3131
- [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters)
3232
- [How to add a new dynamic route](#how-to-add-a-new-dynamic-route)
3333
- [Migrating from backTo to dynamic routes](#migrating-from-backto-to-dynamic-routes)
34+
- [Backward compatibility for changed paths](#backward-compatibility-for-changed-paths)
3435
- [How to remove backTo from URL (Legacy)](#how-to-remove-backto-from-url)
3536
- [Separating routes for each screen instance](#separating-routes-for-each-screen-instance)
3637
- [Generating state from a path](#generating-state-from-a-path)
@@ -931,6 +932,46 @@ If you are migrating an existing flow that uses `backTo` (or multiple static rou
931932

932933
For the legacy approach (e.g. separating routes per screen instance) and edge cases like RHP underlay, see [How to remove backTo from URL](#how-to-remove-backto-from-url) and [Separating routes for each screen instance](#separating-routes-for-each-screen-instance).
933934

935+
### Backward compatibility for changed paths
936+
937+
When migrating a static route to a dynamic route, the resulting URL often changes because dynamic routes build the target path on top of the entry screen's URL. Here is a concrete example:
938+
939+
**Static routes**the entry screen and the target screen have independent, unrelated paths:
940+
941+
| | Path |
942+
|---|---|
943+
| Entry screen | `/r/:reportID/details` |
944+
| Target screen | `/r/:reportID/settings/name` |
945+
946+
The target path `/r/:reportID/settings/name` stands on its own; it does not contain the entry screen's `/details` segment.
947+
948+
**Dynamic routes**the target URL is formed by appending a suffix to the entry screen's URL:
949+
950+
| | Path |
951+
|---|---|
952+
| Entry screen (base path) | `/r/:reportID/details` |
953+
| Dynamic suffix | `settings/name` |
954+
| Target screen | `/r/:reportID/details/settings/name` |
955+
956+
Because the suffix `settings/name` is appended to `/r/:reportID/details`, the resulting URL now includes the entry screen's path as a prefix. The old static URL (`/r/:reportID/settings/name`) no longer matches the new dynamic one (`/r/:reportID/details/settings/name`).
957+
958+
If the new path does not match the old one, you **must** add a redirect mapping to [`src/libs/Navigation/linkingConfig/OldRoutes.ts`](../src/libs/Navigation/linkingConfig/OldRoutes.ts) so that bookmarks, shared links, and browser history still work.
959+
960+
Each entry in `OldRoutes.ts` maps an old path pattern to the new path. Use `*` as a wildcard for dynamic segments and `$1`, `$2`, etc. in the replacement to reference captured segments:
961+
962+
```ts
963+
const oldRoutes: Record<string, string> = {
964+
'/r/*/settings/name': '/r/$1/details/settings/name',
965+
'/workspaces/*/accounting/*/card-reconciliation/account': '/workspaces/$1/accounting/$2/card-reconciliation/account-reconciliation-settings',
966+
};
967+
```
968+
969+
- A `*` in the **middle** of a pattern matches a single path segment (everything except `/`).
970+
- A `*` at the **end** of a pattern matches the entire remaining path (including `/`).
971+
- `$1`, `$2`, ... in the replacement string correspond to the captured wildcards in order.
972+
973+
After adding a mapping, add test cases in [`tests/navigation/getMatchingNewRouteTest.ts`](../tests/navigation/getMatchingNewRouteTest.ts) to verify that the old path redirects to the new one and that query parameters are preserved.
974+
934975
## How to remove backTo from URL
935976

936977
**Preferred approach:** For new flows where a screen is reused across multiple entry points, use [Dynamic Routes](#dynamic-routes) and the [Migrating from backTo to Dynamic Routes](#migrating-from-backto-to-dynamic-routes) guide instead of `backTo` or multiple static routes.

src/ROUTES.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,12 +1020,6 @@ const ROUTES = {
10201020
// eslint-disable-next-line no-restricted-syntax -- Legacy route generation
10211021
getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings` as const, backTo),
10221022
},
1023-
REPORT_SETTINGS_NAME: {
1024-
route: 'r/:reportID/settings/name',
1025-
1026-
// eslint-disable-next-line no-restricted-syntax -- Legacy route generation
1027-
getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings/name` as const, backTo),
1028-
},
10291023
REPORT_CHANGE_APPROVER: {
10301024
route: 'r/:reportID/change-approver',
10311025

src/libs/Navigation/helpers/getAdaptedStateFromPath.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import isDynamicRouteScreen from './dynamicRoutesUtils/isDynamicRouteScreen';
1717
import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard';
1818
import getMatchingNewRoute from './getMatchingNewRoute';
1919
import getParamsFromRoute from './getParamsFromRoute';
20-
import getRedirectedPath from './getRedirectedPath';
2120
import getStateFromPath from './getStateFromPath';
2221
import {isFullScreenName} from './isNavigatorName';
2322
import normalizePath from './normalizePath';
@@ -346,7 +345,6 @@ function getAdaptedState(state: PartialState<NavigationState<RootNavigatorParamL
346345
// We keep `options` in the signature for `linkingConfig` compatibility with react-navigation.
347346
const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldReplacePathInNestedState = true) => {
348347
let normalizedPath = !path.startsWith('/') ? `/${path}` : path;
349-
normalizedPath = getRedirectedPath(normalizedPath);
350348
normalizedPath = getMatchingNewRoute(normalizedPath) ?? normalizedPath;
351349

352350
// Bing search results still link to /signin when searching for “Expensify”, but the /signin route no longer exists in our repo, so we redirect it to the home page to avoid showing a Not Found page.
Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,69 @@
11
import oldRoutes from '@navigation/linkingConfig/OldRoutes';
22

3+
const escapeRegExp = (str: string) => str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
4+
5+
/**
6+
* Converts an OldRoutes pattern string into a RegExp.
7+
*
8+
* A trailing `*` (last in the pattern) matches any remaining characters (multi-segment).
9+
* A non-trailing `*` matches exactly one path segment.
10+
*
11+
* Captured groups can be referenced in the replacement via `$1`, `$2`, etc.
12+
*
13+
* @private - Internal helper. Do not export or use outside this file.
14+
*/
15+
function patternToRegex(pattern: string): RegExp {
16+
const parts = pattern.split('*');
17+
let regexStr = '^';
18+
19+
for (let i = 0; i < parts.length; i++) {
20+
regexStr += escapeRegExp(parts.at(i) ?? '');
21+
if (i < parts.length - 1) {
22+
const isTrailing = i === parts.length - 2 && pattern.endsWith('*');
23+
regexStr += isTrailing ? '(.*)' : '([^/]+)';
24+
}
25+
}
26+
27+
regexStr += '(?=[?#]|$)';
28+
29+
return new RegExp(regexStr);
30+
}
31+
332
/**
433
* Maps an old route path to its corresponding new route based on the `oldRoutes` map.
534
* It finds the best matching pattern (with wildcard `*` support) and replaces the matched
635
* part of the path with the new route value.
736
*
37+
* Wildcards:
38+
* - Trailing `*` matches any remaining path (including `/`).
39+
* - Non-trailing `*` matches exactly one path segment.
40+
* - Use `$1`, `$2`, … in the replacement string to reference captured wildcards.
41+
*
842
* @param path - The input URL path to match and transform.
943
* @returns The new route path if a match is found, otherwise `undefined`.
1044
*
1145
* Related issue: https://github.com/Expensify/App/issues/64968
1246
*/
1347
function getMatchingNewRoute(path: string) {
14-
let bestMatch;
48+
let bestMatch: string | undefined;
49+
let bestRegex: RegExp | undefined;
1550
let maxLength = -1;
1651

1752
for (const pattern of Object.keys(oldRoutes)) {
18-
const regexStr = `^${pattern.replace('*', '.*')}`;
19-
const regex = new RegExp(regexStr);
53+
const regex = patternToRegex(pattern);
2054

2155
if (regex.test(path) && pattern.length > maxLength) {
2256
bestMatch = pattern;
57+
bestRegex = regex;
2358
maxLength = pattern.length;
2459
}
2560
}
26-
if (!bestMatch) {
27-
return bestMatch;
61+
62+
if (!bestMatch || !bestRegex) {
63+
return undefined;
2864
}
2965

30-
const finalRegexp = bestMatch?.replace('*', '') ?? '';
31-
return path.replace(finalRegexp, oldRoutes[bestMatch]);
66+
return path.replace(bestRegex, oldRoutes[bestMatch]);
3267
}
68+
3369
export default getMatchingNewRoute;

src/libs/Navigation/helpers/getRedirectedPath.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

src/libs/Navigation/helpers/getStateFromPath.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,14 @@ import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDyna
1111
import getStateForDynamicRoute from './dynamicRoutesUtils/getStateForDynamicRoute';
1212
import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard';
1313
import getMatchingNewRoute from './getMatchingNewRoute';
14-
import getRedirectedPath from './getRedirectedPath';
1514

1615
/**
1716
* @param path - The path to parse
1817
* @returns - It's possible that there is no navigation action for the given path
1918
*/
2019
function getStateFromPath(path: Route): PartialState<NavigationState> {
2120
const normalizedPath = !path.startsWith('/') ? `/${path}` : path;
22-
const redirectedPath = getRedirectedPath(normalizedPath);
23-
const normalizedPathAfterRedirection = getMatchingNewRoute(redirectedPath) ?? redirectedPath;
21+
const normalizedPathAfterRedirection = getMatchingNewRoute(normalizedPath) ?? normalizedPath;
2422

2523
const suffixMatch = findMatchingDynamicSuffix(normalizedPathAfterRedirection);
2624
if (suffixMatch) {
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
const oldRoutes: Record<string, string> = {
2-
// eslint-disable-next-line @typescript-eslint/naming-convention
3-
'/settings/workspaces/*': '/workspaces/',
4-
// eslint-disable-next-line @typescript-eslint/naming-convention
2+
/* eslint-disable @typescript-eslint/naming-convention */
3+
'/settings/workspaces/*': '/workspaces/$1',
54
'/settings/workspaces': '/workspaces',
5+
'/r/*/settings/name': '/r/$1/details/settings/name',
6+
'/workspaces/*/overview/address': '/workspaces/$1/overview/workspace-address',
7+
'/workspaces/*/accounting/*/card-reconciliation/account': '/workspaces/$1/accounting/$2/card-reconciliation/account-reconciliation-settings',
8+
'/flag/*/*': '/r/$1/flag/$1/$2',
9+
'/home-page': '/home',
10+
/* eslint-enable @typescript-eslint/naming-convention */
611
};
712

813
export default oldRoutes;

tests/navigation/getMatchingNewRouteTest.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,46 @@ describe('getBestMatchingPath', () => {
2222
expect(getMatchingNewRoute('/anything/anything/')).toBe(undefined);
2323
expect(getMatchingNewRoute('/anything/anything/anything')).toBe(undefined);
2424
});
25+
26+
it('redirects old report settings name path to report details', () => {
27+
expect(getMatchingNewRoute('/r/123/settings/name')).toBe('/r/123/details/settings/name');
28+
});
29+
30+
it('preserves query params when redirecting report settings name', () => {
31+
expect(getMatchingNewRoute('/r/123/settings/name?backTo=/home')).toBe('/r/123/details/settings/name?backTo=/home');
32+
});
33+
34+
it('redirects old workspace overview address path', () => {
35+
expect(getMatchingNewRoute('/workspaces/abc/overview/address')).toBe('/workspaces/abc/overview/workspace-address');
36+
});
37+
38+
it('redirects old card reconciliation account path with two wildcards', () => {
39+
expect(getMatchingNewRoute('/workspaces/abc/accounting/xero/card-reconciliation/account')).toBe(
40+
'/workspaces/abc/accounting/xero/card-reconciliation/account-reconciliation-settings',
41+
);
42+
expect(getMatchingNewRoute('/workspaces/abc/accounting/netsuite/card-reconciliation/account')).toBe(
43+
'/workspaces/abc/accounting/netsuite/card-reconciliation/account-reconciliation-settings',
44+
);
45+
});
46+
47+
it('redirects old flag comment path to report-based dynamic route', () => {
48+
expect(getMatchingNewRoute('/flag/123/456')).toBe('/r/123/flag/123/456');
49+
});
50+
51+
it('does not redirect paths that look similar but do not match migrated patterns', () => {
52+
expect(getMatchingNewRoute('/r/123/settings/visibility')).toBe(undefined);
53+
expect(getMatchingNewRoute('/workspaces/abc/overview/plan')).toBe(undefined);
54+
expect(getMatchingNewRoute('/workspaces/abc/accounting/xero/card-reconciliation/settings')).toBe(undefined);
55+
});
56+
57+
it('does not match extensions of exact patterns', () => {
58+
expect(getMatchingNewRoute('/home-page2')).toBe(undefined);
59+
expect(getMatchingNewRoute('/home-page/extra')).toBe(undefined);
60+
expect(getMatchingNewRoute('/r/123/settings/name-extra')).toBe(undefined);
61+
expect(getMatchingNewRoute('/workspaces/abc/overview/address/sub')).toBe(undefined);
62+
});
63+
64+
it('preserves fragment when redirecting', () => {
65+
expect(getMatchingNewRoute('/home-page?backTo=r/123')).toBe('/home?backTo=r/123');
66+
});
2567
});

tests/navigation/getStateFromPathTests.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ jest.mock('@src/ROUTES', () => ({
5252
}));
5353

5454
jest.mock('@libs/Navigation/helpers/getMatchingNewRoute', () => jest.fn());
55-
jest.mock('@libs/Navigation/helpers/getRedirectedPath', () => jest.fn((path: string) => path));
5655
jest.mock('@libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute', () => jest.fn());
5756

5857
describe('getStateFromPath', () => {

0 commit comments

Comments
 (0)