Skip to content

Commit cc54882

Browse files
authored
Merge pull request Expensify#88589 from software-mansion-labs/collectioneur/dynamic-routes-optional-path-parameters
2 parents 07baea8 + 9b93075 commit cc54882

16 files changed

Lines changed: 660 additions & 212 deletions

contributingGuides/NAVIGATION.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -750,11 +750,49 @@ KEYBOARD_SHORTCUTS: {
750750
> and makes it harder to reason about which screens can trigger a given flow.
751751
> When in doubt, prefer an explicit list.
752752

753-
### Current limitations (work in progress)
753+
### Optional path parameters
754754

755-
- **Optional path parameters:** Suffixes must not include optional path params (e.g. `a/:reportID?`). Required path parameters (e.g. `flag/:reportActionID`) and query parameters are supported - see [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters).
755+
Dynamic route suffixes may declare optional path parameters by appending `?` to the parameter name.
756+
An optional parameter can be present or absent in the URL; when absent, the corresponding key is
757+
omitted from the navigation state's `params` (it is **not** set to `undefined`).
756758

757-
If you try to use dynamic routes with optional path parameters 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.
759+
Optionals can appear anywhere in the suffix:
760+
761+
- **Trailing optional:** `member-details/:accountID?` matches both `member-details` and `member-details/123`.
762+
- **Middle optional:** `wrap/:p?/end` matches both `wrap/end` and `wrap/x/end`.
763+
- **Multiple optionals:** `a/:p1?/b/:p2?` matches `a/b`, `a/x/b`, `a/b/y`, and `a/x/b/y`.
764+
765+
#### Configuration example
766+
767+
```ts
768+
DYNAMIC_ROUTES: {
769+
MEMBER_DETAILS: {
770+
path: 'member-details/:accountID?',
771+
entryScreens: [SCREENS.WORKSPACE.MEMBERS],
772+
getRoute: (accountID?: string) => (accountID ? `member-details/${accountID}` : 'member-details'),
773+
},
774+
},
775+
```
776+
777+
#### Precedence rules
778+
779+
When several registered suffixes could match the same URL, the matcher uses a deterministic
780+
three-phase order. Each phase exhausts all sub-suffix lengths (longest to shortest) before the
781+
next phase begins:
782+
783+
1. **Static beats everything.** All sub-suffix lengths are checked for an exact static match
784+
first. A short static match (e.g. single-segment `country`) always beats a longer parametric
785+
match (e.g. `:reportID/country`).
786+
2. **Strict parametric beats optional parametric.** After statics, all sub-suffix lengths are
787+
checked for strict parametric patterns (no optional params, e.g. `flag/:reportID/:reportActionID`).
788+
Only after those are exhausted does the matcher try optional parametric patterns
789+
(e.g. `page/:id?`).
790+
3. **Longest match wins within each phase.** Within a single phase the algorithm iterates from
791+
the longest candidate to the shortest, so a 3-segment match beats a 2-segment one when both
792+
are registered.
793+
4. **Among patterns of the same kind: first registered wins.** If multiple patterns of the same
794+
type (strict or optional) could match the same candidate, the one declared first in
795+
`DYNAMIC_ROUTES` wins.
758796

759797
### Multi-segment dynamic routes
760798

src/hooks/useDynamicBackPath.ts

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1+
import findMatchingDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/findMatchingDynamicSuffix';
12
import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix';
2-
import matchPathPattern from '@libs/Navigation/helpers/dynamicRoutesUtils/matchPathPattern';
3-
import splitPathAndQuery from '@libs/Navigation/helpers/dynamicRoutesUtils/splitPathAndQuery';
43
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
54
import type {State} from '@libs/Navigation/types';
65
import type {DynamicRouteSuffix, Route} from '@src/ROUTES';
76
import ROUTES from '@src/ROUTES';
87
import useRootNavigationState from './useRootNavigationState';
98

109
/**
11-
* Returns the back path for a dynamic route by removing the dynamic suffix from the current URL.
12-
* Supports both static suffixes (exact string match) and parametric suffixes (pattern matching).
13-
* Only removes the suffix if it's the last segment(s) of the path.
14-
* @param dynamicRouteSuffix - The dynamic route suffix to remove from the current URL.
10+
* Removes the given dynamic suffix from the end of the current URL to produce a "back" path.
11+
*
12+
* Supported suffix types: static (`/edit`), parametric (`/:reportID`),
13+
* and parametric with optional params (`/:reportID/:reportActionID?`).
14+
*
15+
* If the suffix doesn't match the tail of the current path, returns the path as-is.
16+
*
17+
* @param dynamicRouteSuffix - The dynamic route pattern to remove from the current URL.
1518
* @returns The back path for the dynamic route.
1619
*/
1720
function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route {
@@ -27,34 +30,12 @@ function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route {
2730
return ROUTES.HOME;
2831
}
2932

30-
// Remove leading slashes for consistent processing
31-
const pathWithoutLeadingSlash = path.replace(/^\/+/, '');
32-
const [normalizedPath] = splitPathAndQuery(pathWithoutLeadingSlash);
33-
34-
if (!normalizedPath) {
35-
return pathWithoutLeadingSlash as Route;
36-
}
37-
38-
// Fast path: exact string match (static suffixes)
39-
if (normalizedPath.endsWith(`/${dynamicRouteSuffix}`)) {
40-
return getPathWithoutDynamicSuffix(pathWithoutLeadingSlash, dynamicRouteSuffix);
41-
}
42-
43-
// Parametric path: take the last N segments and try pattern matching
44-
const patternSegmentCount = dynamicRouteSuffix.split('/').filter(Boolean).length;
45-
const pathSegments = normalizedPath.split('/').filter(Boolean);
46-
47-
if (patternSegmentCount > 0 && patternSegmentCount <= pathSegments.length) {
48-
const tailSegments = pathSegments.slice(-patternSegmentCount);
49-
const tailCandidate = tailSegments.join('/');
50-
const match = matchPathPattern(tailCandidate, dynamicRouteSuffix);
51-
52-
if (match) {
53-
return getPathWithoutDynamicSuffix(pathWithoutLeadingSlash, tailCandidate, dynamicRouteSuffix);
54-
}
33+
const pathWithoutLeadingSlash = path.replaceAll(/^\/+/g, '');
34+
const match = findMatchingDynamicSuffix(pathWithoutLeadingSlash);
35+
if (match && match.pattern === dynamicRouteSuffix) {
36+
return getPathWithoutDynamicSuffix(pathWithoutLeadingSlash, match.actualSuffix, match.pattern);
5537
}
5638

57-
// No match found - the suffix is not at the end of the current path.
5839
return pathWithoutLeadingSlash as Route;
5940
}
6041

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import StringUtils from '@libs/StringUtils';
2+
import {DYNAMIC_ROUTES} from '@src/ROUTES';
3+
4+
/**
5+
* Compiles a dynamic-route suffix pattern (e.g. `flag/:reportID/:reportActionID?`) into a regex.
6+
* Used to detect and strip dynamic suffixes from URL paths.
7+
*/
8+
9+
type CompiledPattern = {
10+
/** Original pattern string, e.g. `'a/:p?/b'`. */
11+
pattern: string;
12+
13+
/** Compiled regex. */
14+
regex: RegExp;
15+
16+
/** Names of all `:param` and `:param?` placeholders in declaration order. */
17+
paramNames: string[];
18+
19+
/** Minimum number of URL segments that can satisfy this pattern (all optionals absent). */
20+
minSegments: number;
21+
22+
/** Maximum number of URL segments that can satisfy this pattern (all optionals present). */
23+
maxSegments: number;
24+
};
25+
26+
function compileDynamicRoutePattern(pattern: string): CompiledPattern {
27+
if (!pattern) {
28+
throw new Error(`[compileDynamicRoutePattern] Pattern must be a non-empty string, got "${pattern}"`);
29+
}
30+
31+
const segments = pattern.split('/');
32+
33+
// Reject patterns with empty segments (e.g. 'a//b'), but allow a trailing slash.
34+
if (segments.some((s, i) => s === '' && i !== segments.length - 1)) {
35+
throw new Error(`[compileDynamicRoutePattern] Pattern "${pattern}" contains an empty segment`);
36+
}
37+
38+
const paramNames: string[] = [];
39+
const seenParamNames = new Set<string>();
40+
let minSegments = 0;
41+
let maxSegments = 0;
42+
let regexBody = '';
43+
44+
for (const segment of segments) {
45+
if (segment === '') {
46+
continue;
47+
}
48+
49+
if (segment.startsWith(':')) {
50+
const optional = segment.endsWith('?');
51+
const name = optional ? segment.slice(1, -1) : segment.slice(1);
52+
53+
if (!name || name.includes(':')) {
54+
throw new Error(`[compileDynamicRoutePattern] Pattern "${pattern}" contains a malformed param "${segment}"`);
55+
}
56+
if (seenParamNames.has(name)) {
57+
throw new Error(`[compileDynamicRoutePattern] Pattern "${pattern}" declares duplicate param "${name}"`);
58+
}
59+
seenParamNames.add(name);
60+
paramNames.push(name);
61+
62+
if (optional) {
63+
regexBody += `(?:(?<${name}>[^/]+)\\/)?`;
64+
maxSegments += 1;
65+
} else {
66+
regexBody += `(?<${name}>[^/]+)\\/`;
67+
minSegments += 1;
68+
maxSegments += 1;
69+
}
70+
} else {
71+
regexBody += `${StringUtils.escapeRegExp(segment)}\\/`;
72+
minSegments += 1;
73+
maxSegments += 1;
74+
}
75+
}
76+
77+
if (paramNames.length === 0 && minSegments === 0) {
78+
throw new Error(`[compileDynamicRoutePattern] Pattern "${pattern}" has no segments`);
79+
}
80+
81+
const regex = new RegExp(`^${regexBody}$`);
82+
return {pattern, regex, paramNames, minSegments, maxSegments};
83+
}
84+
85+
type CompiledEntry = {key: string; compiled: CompiledPattern};
86+
87+
const compiledParametricDynamicRoutes: CompiledEntry[] = Object.entries(DYNAMIC_ROUTES)
88+
.filter(([, entry]) => entry.path.includes(':'))
89+
.map(([key, entry]) => ({key, compiled: compileDynamicRoutePattern(entry.path)}));
90+
91+
const compiledStrictParametricDynamicRoutes = compiledParametricDynamicRoutes.filter(({compiled}) => compiled.minSegments === compiled.maxSegments);
92+
const compiledOptionalParametricDynamicRoutes = compiledParametricDynamicRoutes.filter(({compiled}) => compiled.minSegments < compiled.maxSegments);
93+
94+
export default compileDynamicRoutePattern;
95+
export {compiledStrictParametricDynamicRoutes, compiledOptionalParametricDynamicRoutes};
96+
export type {CompiledEntry};

src/libs/Navigation/helpers/dynamicRoutesUtils/findMatchingDynamicSuffix.ts

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import {dynamicRoutePaths, parametricEntriesBySegmentCount} from './isDynamicRouteSuffix';
2-
import matchPathPattern from './matchPathPattern';
1+
import type {CompiledEntry} from './compileDynamicRoutePattern';
2+
import {compiledOptionalParametricDynamicRoutes, compiledStrictParametricDynamicRoutes} from './compileDynamicRoutePattern';
3+
import {dynamicRoutePaths} from './isDynamicRouteSuffix';
34
import splitPathAndQuery from './splitPathAndQuery';
45

56
type DynamicSuffixMatch = {
@@ -11,11 +12,53 @@ type DynamicSuffixMatch = {
1112
pathParams: Record<string, string>;
1213
};
1314

15+
/**
16+
* Tries to match a candidate suffix against a list of compiled parametric patterns.
17+
* Returns the first match with extracted path params, or undefined.
18+
*
19+
* @private - Internal helper. Do not export or use outside this file.
20+
*/
21+
function tryMatchParametric(candidate: string, candidateSegmentCount: number, patterns: CompiledEntry[]): DynamicSuffixMatch | undefined {
22+
const normalized = candidate.endsWith('/') ? candidate : `${candidate}/`;
23+
24+
for (const {compiled} of patterns) {
25+
if (candidateSegmentCount < compiled.minSegments || candidateSegmentCount > compiled.maxSegments) {
26+
continue;
27+
}
28+
const match = compiled.regex.exec(normalized);
29+
if (!match) {
30+
continue;
31+
}
32+
const pathParams: Record<string, string> = {};
33+
for (const name of compiled.paramNames) {
34+
const value = match.groups?.[name];
35+
if (value === undefined) {
36+
continue;
37+
}
38+
try {
39+
pathParams[name] = decodeURIComponent(value);
40+
} catch {
41+
pathParams[name] = value;
42+
}
43+
}
44+
return {pattern: compiled.pattern, actualSuffix: candidate, pathParams};
45+
}
46+
47+
return undefined;
48+
}
49+
1450
/**
1551
* Finds a registered dynamic route suffix that matches the end of the given path.
16-
* Iterates path sub-suffixes from longest to shortest and checks each against
17-
* registered dynamic paths. Supports both exact static matches and parametric
18-
* pattern matches (e.g. 'flag/:reportID/:reportActionID').
52+
*
53+
* Uses three-phase matching with decreasing specificity. Each phase iterates
54+
* all sub-suffixes from longest to shortest before the next phase begins:
55+
* 1. Static matches (`dynamicRoutePaths` Set lookup).
56+
* 2. Strict parametric patterns (no optional params).
57+
* 3. Optional parametric patterns (has at least one `:param?`).
58+
*
59+
* This guarantees that any static match, even a short one, always beats
60+
* a parametric match, and any strict-parametric match always beats an
61+
* optional-parametric match.
1962
*
2063
* @param path - The path to find the matching dynamic suffix for
2164
* @returns The matching dynamic suffix, or undefined if no matching suffix is found
@@ -28,26 +71,27 @@ function findMatchingDynamicSuffix(path = ''): DynamicSuffixMatch | undefined {
2871

2972
const segments = normalizedPath.split('/').filter(Boolean);
3073

31-
// Iterate from the full path (longest candidate) down to single-segment suffixes.
32-
// This guarantees the longest matching suffix is returned first.
74+
// Phase 1: Static matches (longest to shortest)
3375
for (let i = 0; i < segments.length; i++) {
3476
const candidate = segments.slice(i).join('/');
35-
36-
// Static match (e.g. 'country', 'verify-account')
3777
if (dynamicRoutePaths.has(candidate)) {
3878
return {pattern: candidate, actualSuffix: candidate, pathParams: {}};
3979
}
80+
}
81+
82+
// Phase 2: Strict parametric patterns - no optional params (longest to shortest)
83+
for (let i = 0; i < segments.length; i++) {
84+
const result = tryMatchParametric(segments.slice(i).join('/'), segments.length - i, compiledStrictParametricDynamicRoutes);
85+
if (result) {
86+
return result;
87+
}
88+
}
4089

41-
// Parametric pattern match - only check patterns with the same segment count.
42-
const candidateSegmentCount = segments.length - i;
43-
const matchingEntries = parametricEntriesBySegmentCount.get(candidateSegmentCount);
44-
if (matchingEntries) {
45-
for (const entry of matchingEntries) {
46-
const match = matchPathPattern(candidate, entry.path);
47-
if (match) {
48-
return {pattern: entry.path, actualSuffix: candidate, pathParams: match.params};
49-
}
50-
}
90+
// Phase 3: Optional parametric patterns - has at least one :param? (longest to shortest)
91+
for (let i = 0; i < segments.length; i++) {
92+
const result = tryMatchParametric(segments.slice(i).join('/'), segments.length - i, compiledOptionalParametricDynamicRoutes);
93+
if (result) {
94+
return result;
5195
}
5296
}
5397

src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,14 @@ function getStateForDynamicRoute(path: string, dynamicRouteName: keyof typeof DY
6767
const buildNestedState = (routes: string[], currentIndex: number): RouteNode => {
6868
const currentRoute = routes.at(currentIndex);
6969

70-
// If this is the last route, create leaf node with path and merged params
70+
// If this is the last route, create leaf node with path and merged params.
71+
// Filter out undefined values so absent optional path params don't surface as
72+
// explicit `{key: undefined}` entries in the navigation state.
7173
if (currentIndex === routes.length - 1) {
72-
const mergedParams = parentRouteParams || params ? {...(parentRouteParams ?? {}), ...(params ?? {})} : undefined;
73-
const paramsSpread = mergedParams ? {params: mergedParams} : {};
74+
const mergedParams = {...(parentRouteParams ?? {}), ...(params ?? {})};
75+
const cleanedParams = Object.fromEntries(Object.entries(mergedParams).filter(([, v]) => v !== undefined));
76+
const paramsSpread = cleanedParams && Object.keys(cleanedParams).length > 0 ? {params: cleanedParams} : {};
77+
7478
return {
7579
name: currentRoute ?? '',
7680
path,

0 commit comments

Comments
 (0)