Skip to content

Commit 5f7e6bc

Browse files
authored
Merge pull request Expensify#85940 from software-mansion-labs/collectioneur/dynamic-routes-wildcard-access
2 parents 8a34969 + af5b264 commit 5f7e6bc

4 files changed

Lines changed: 53 additions & 4 deletions

File tree

contributingGuides/NAVIGATION.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,7 @@ Do not use dynamic routes when:
710710
`DYNAMIC_ROUTES` in `src/ROUTES.ts`: each entry has:
711711

712712
- `path`: The URL suffix (e.g. `'verify-account'`).
713-
- `entryScreens`: List of screen names that are allowed to have this suffix appended (access control; see [Entry Screens (Access Control)](#entry-screens-access-control)).
713+
- `entryScreens`: List of screen names that are allowed to have this suffix appended (access control; see [Entry Screens (Access Control)](#entry-screens-access-control)). Use `['*']` to allow all screens.
714714

715715
`createDynamicRoute(suffix)` — [`createDynamicRoute.ts`](src/libs/Navigation/helpers/createDynamicRoute.ts). Accepts a `DynamicRouteSuffix` (from `DYNAMIC_ROUTES`), appends it to the current active route and returns the full route. Use the following when navigating to a dynamic route:
716716

@@ -731,6 +731,24 @@ When parsing a URL, `src/libs/Navigation/helpers/getStateFromPath.ts` resolves t
731731

732732
When adding or extending a dynamic route, list every screen that should be able to open it (e.g. `SCREENS.SETTINGS.WALLET.ROOT` for Verify Account from Wallet).
733733

734+
#### Wildcard access (`'*'`)
735+
736+
Setting `entryScreens` to `['*']` grants access to the dynamic route from any screen. This bypasses per-screen authorization entirely for that route.
737+
738+
```ts
739+
KEYBOARD_SHORTCUTS: {
740+
path: 'keyboard-shortcuts',
741+
entryScreens: ['*'],
742+
},
743+
```
744+
745+
> [!CAUTION]
746+
> **Use `'*'` only when the dynamic route genuinely needs to be reachable from every screen.**
747+
> If only a subset of screens should access the route, list them explicitly.
748+
> Overusing `'*'` weakens the access control that `entryScreens` provides
749+
> and makes it harder to reason about which screens can trigger a given flow.
750+
> When in doubt, prefer an explicit list.
751+
734752
### Current limitations (work in progress)
735753

736754
- **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).

src/ROUTES.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const VERIFY_ACCOUNT = 'verify-account';
5555

5656
type DynamicRouteConfig = {
5757
path: string;
58-
entryScreens: Screen[];
58+
entryScreens: ReadonlyArray<Screen | '*'>;
5959
getRoute?: (...args: never[]) => string;
6060
queryParams?: readonly string[];
6161
};

src/libs/Navigation/helpers/getStateFromPath.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ function getStateFromPath(path: Route): PartialState<NavigationState> {
3434

3535
// Get the currently focused route from the base path to check permissions
3636
const focusedRoute = findFocusedRouteWithOnyxTabGuard(getStateFromPath(pathWithoutDynamicSuffix) ?? {});
37-
const entryScreens: Screen[] = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? [];
37+
const entryScreens: ReadonlyArray<Screen | '*'> = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? [];
3838

3939
// Check if the focused route is allowed to access this dynamic route
4040
if (focusedRoute?.name) {
41-
if (entryScreens.includes(focusedRoute.name as Screen)) {
41+
if (entryScreens.some((s) => s === '*' || s === focusedRoute.name)) {
4242
// Generate navigation state for the dynamic route
4343
const dynamicRouteState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey, focusedRoute?.params as Record<string, unknown> | undefined);
4444
return dynamicRouteState;

tests/navigation/getStateFromPathTests.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ jest.mock('@src/ROUTES', () => ({
4444
path: 'suffix-b-from-multi',
4545
entryScreens: ['DynamicMultiSegScreen'],
4646
},
47+
WILDCARD_SUFFIX: {
48+
path: 'wildcard-suffix',
49+
entryScreens: ['*'],
50+
},
4751
},
4852
}));
4953

@@ -62,6 +66,7 @@ describe('getStateFromPath', () => {
6266
const dynamicSuffixBState = {routes: [{name: 'DynamicSuffixBScreen'}]};
6367
const dynamicMultiSegState = {routes: [{name: 'DynamicMultiSegScreen', params: focusedRouteParams}]};
6468
const dynamicMultiSegLayerState = {routes: [{name: 'DynamicMultiSegLayerScreen'}]};
69+
const dynamicWildcardState = {routes: [{name: 'DynamicWildcardScreen'}]};
6570

6671
beforeEach(() => {
6772
jest.clearAllMocks();
@@ -79,6 +84,9 @@ describe('getStateFromPath', () => {
7984
if (dynamicRouteKey === 'MULTI_SEG_LAYER') {
8085
return dynamicMultiSegLayerState;
8186
}
87+
if (dynamicRouteKey === 'WILDCARD_SUFFIX') {
88+
return dynamicWildcardState;
89+
}
8290
return {routes: [{name: 'UnknownDynamic'}]};
8391
});
8492
});
@@ -159,4 +167,27 @@ describe('getStateFromPath', () => {
159167
expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith(fullPath, 'MULTI_SEG_LAYER', focusedRouteParams);
160168
});
161169
});
170+
171+
describe('wildcard entryScreens', () => {
172+
it('should authorize any focused screen when entryScreens contains wildcard', () => {
173+
const fullPath = '/base/wildcard-suffix';
174+
175+
const result = getStateFromPath(fullPath as unknown as Route);
176+
177+
expect(result).toBe(dynamicWildcardState);
178+
expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith(fullPath, 'WILDCARD_SUFFIX', focusedRouteParams);
179+
expect(mockLogWarn).not.toHaveBeenCalled();
180+
});
181+
182+
it('should authorize wildcard in a layered scenario where the inner screen is not explicitly listed', () => {
183+
const fullPath = '/base/suffix-a/wildcard-suffix';
184+
185+
const result = getStateFromPath(fullPath as unknown as Route);
186+
187+
expect(result).toBe(dynamicWildcardState);
188+
expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith('/base/suffix-a', 'SUFFIX_A', focusedRouteParams);
189+
expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith(fullPath, 'WILDCARD_SUFFIX', focusedRouteParams);
190+
expect(mockLogWarn).not.toHaveBeenCalled();
191+
});
192+
});
162193
});

0 commit comments

Comments
 (0)