Skip to content
Draft
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
30 changes: 23 additions & 7 deletions src/hooks/useDynamicBackPath.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import getPathWithoutDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix';
import matchPathPattern from '@libs/Navigation/helpers/dynamicRoutesUtils/matchPathPattern';
import splitPathAndQuery from '@libs/Navigation/helpers/dynamicRoutesUtils/splitPathAndQuery';
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
import type {State} from '@libs/Navigation/types';
Expand All @@ -8,9 +9,8 @@ import useRootNavigationState from './useRootNavigationState';

/**
* Returns the back path for a dynamic route by removing the dynamic suffix from the current URL.
* Only removes the suffix if it's the last segment of the path (ignoring trailing slashes and query parameters).
* @param dynamicRouteSuffix The dynamic route suffix to remove from the current path
* @returns The back path without the dynamic route suffix, or HOME if path is null/undefined
* Supports both static suffixes (exact string match) and parametric suffixes (pattern matching).
* Only removes the suffix if it's the last segment(s) of the path.
*/
function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route {
const path = useRootNavigationState((state) => {
Expand All @@ -25,16 +25,32 @@ function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route {
return ROUTES.HOME;
}

// Remove leading slashes for consistent processing
const pathWithoutLeadingSlash = path.replace(/^\/+/, '');

const [normalizedPath] = splitPathAndQuery(pathWithoutLeadingSlash);

if (normalizedPath?.endsWith(`/${dynamicRouteSuffix}`)) {
if (!normalizedPath) {
return pathWithoutLeadingSlash as Route;
}

// Fast path: exact string match (static suffixes)
if (normalizedPath.endsWith(`/${dynamicRouteSuffix}`)) {
return getPathWithoutDynamicSuffix(pathWithoutLeadingSlash, dynamicRouteSuffix);
}

// If suffix is not the last segment, return the original path
// Parametric path: take the last N segments and try pattern matching
const patternSegmentCount = dynamicRouteSuffix.split('/').filter(Boolean).length;
const pathSegments = normalizedPath.split('/').filter(Boolean);

if (patternSegmentCount > 0 && patternSegmentCount <= pathSegments.length) {
const tailSegments = pathSegments.slice(-patternSegmentCount);
const tailCandidate = tailSegments.join('/');
const match = matchPathPattern(tailCandidate, dynamicRouteSuffix);

if (match) {
return getPathWithoutDynamicSuffix(pathWithoutLeadingSlash, tailCandidate, dynamicRouteSuffix);
}
}

return pathWithoutLeadingSlash as Route;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import isDynamicRouteSuffix from './isDynamicRouteSuffix';
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import {dynamicRoutePaths} from './isDynamicRouteSuffix';
import matchPathPattern from './matchPathPattern';
import splitPathAndQuery from './splitPathAndQuery';

type DynamicSuffixMatch = {
/** Registered pattern, e.g. 'flag/:reportID/:reportActionID' */
pattern: string;
/** Actual URL values, e.g. 'flag/456/abc' */
actualSuffix: string;
/** Extracted path params, e.g. {reportID: '456', reportActionID: 'abc'} */
pathParams: Record<string, string>;
};

const dynamicRouteEntries = Object.values(DYNAMIC_ROUTES);

/**
* Finds a registered dynamic route suffix that matches the end of the given path.
* Iterates path sub-suffixes from longest to shortest and checks each against a
* pre-built Set of registered dynamic paths, ensuring the longest match wins
* when overlapping suffixes exist (e.g. "address/country" vs "country").
* Iterates path sub-suffixes from longest to shortest and checks each against
* registered dynamic paths. Supports both exact static matches and parametric
* pattern matches (e.g. 'flag/:reportID/:reportActionID').
*
* @param path - The path to find the matching dynamic suffix for
* @returns The matching dynamic suffix, or undefined if no matching suffix is found
* Returns the longest match when overlapping suffixes exist.
*/
function findMatchingDynamicSuffix(path = ''): string | undefined {
function findMatchingDynamicSuffix(path = ''): DynamicSuffixMatch | undefined {
const [normalizedPath] = splitPathAndQuery(path);
if (!normalizedPath) {
return undefined;
Expand All @@ -20,12 +32,21 @@ function findMatchingDynamicSuffix(path = ''): string | undefined {

for (let i = 0; i < segments.length; i++) {
const candidate = segments.slice(i).join('/');
if (isDynamicRouteSuffix(candidate)) {
return candidate;

if (dynamicRoutePaths.has(candidate)) {
return {pattern: candidate, actualSuffix: candidate, pathParams: {}};
}

for (const entry of dynamicRouteEntries) {
const match = matchPathPattern(candidate, entry.path);
if (match) {
return {pattern: entry.path, actualSuffix: candidate, pathParams: match.params};
}
}
}

return undefined;
}

export default findMatchingDynamicSuffix;
export type {DynamicSuffixMatch};
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ function getQueryParamsToStrip(dynamicSuffix: string): readonly string[] | undef
* @param dynamicSuffix - The dynamic suffix to strip (e.g., 'country')
* @returns The path without the suffix and with only base-path query params preserved
*/
function getPathWithoutDynamicSuffix(fullPath: string, dynamicSuffix: string): Route {
function getPathWithoutDynamicSuffix(fullPath: string, dynamicSuffix: string, patternSuffix?: string): Route {
const [pathWithoutQuery, query] = splitPathAndQuery(fullPath);
const pathWithoutDynamicSuffix = pathWithoutQuery?.slice(0, -(dynamicSuffix.length + 1)) ?? '';

if (!pathWithoutDynamicSuffix || pathWithoutDynamicSuffix === '/') {
return '';
}

const paramsToStrip = getQueryParamsToStrip(dynamicSuffix);
const paramsToStrip = getQueryParamsToStrip(patternSuffix ?? dynamicSuffix);
let filteredQuery = query;
if (paramsToStrip?.length && query) {
const params = new URLSearchParams(query);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import splitPathAndQuery from './splitPathAndQuery';
type LeafRoute = {
name: string;
path: string;
params?: Record<string, string>;
params?: Record<string, unknown>;
};

type NestedRoute = {
Expand Down Expand Up @@ -54,7 +54,7 @@ function getRouteNamesForDynamicRoute(dynamicRouteName: DynamicRouteSuffix): str
return null;
}

function getStateForDynamicRoute(path: string, dynamicRouteName: keyof typeof DYNAMIC_ROUTES) {
function getStateForDynamicRoute(path: string, dynamicRouteName: keyof typeof DYNAMIC_ROUTES, parentRouteParams?: Record<string, unknown>) {
const routeConfig = getRouteNamesForDynamicRoute(DYNAMIC_ROUTES[dynamicRouteName].path);
const [, query] = splitPathAndQuery(path);
const params = getParamsFromQuery(query);
Expand All @@ -67,12 +67,14 @@ function getStateForDynamicRoute(path: string, dynamicRouteName: keyof typeof DY
const buildNestedState = (routes: string[], currentIndex: number): RouteNode => {
const currentRoute = routes.at(currentIndex);

// If this is the last route, create leaf node with path
// If this is the last route, create leaf node with path and merged params
if (currentIndex === routes.length - 1) {
const mergedParams = parentRouteParams || params ? {...parentRouteParams, ...params} : undefined;
const paramsSpread = mergedParams ? {params: mergedParams} : {};
return {
name: currentRoute ?? '',
path,
params,
...paramsSpread,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import type {DynamicRouteSuffix} from '@src/ROUTES';
import matchPathPattern from './matchPathPattern';

const dynamicRouteEntries = Object.values(DYNAMIC_ROUTES);
const dynamicRoutePaths = new Set<string>(dynamicRouteEntries.map((r) => r.path));

/**
* Checks if a suffix matches any dynamic route path in DYNAMIC_ROUTES.
* Supports both exact static matches and parametric pattern matches.
*/
function isDynamicRouteSuffix(suffix: string): suffix is DynamicRouteSuffix {
return dynamicRoutePaths.has(suffix);
if (dynamicRoutePaths.has(suffix)) {
return true;
}

return dynamicRouteEntries.some((entry) => matchPathPattern(suffix, entry.path) !== null);
}

export {dynamicRoutePaths};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
type PatternMatch = {
params: Record<string, string>;
};

/**
* Matches a URL path candidate against a route pattern with :param placeholders.
* Static segments must match exactly; :param segments capture any value.
* Mirrors React Navigation's segment-by-segment matching approach.
*
* @param candidate - The actual URL suffix, e.g. 'flag/123/abc'
* @param pattern - The registered pattern, e.g. 'flag/:reportID/:reportActionID'
* @returns Match result with extracted params, or null if no match
*/
function matchPathPattern(candidate: string, pattern: string): PatternMatch | null {
const candidateSegments = candidate.split('/').filter(Boolean);
const patternSegments = pattern.split('/').filter(Boolean);

if (candidateSegments.length !== patternSegments.length) {
return null;
}

const params: Record<string, string> = {};

for (let i = 0; i < patternSegments.length; i++) {
const patternSeg = patternSegments.at(i) ?? '';
const candidateSeg = candidateSegments.at(i) ?? '';

if (patternSeg.startsWith(':')) {
params[patternSeg.slice(1)] = decodeURIComponent(candidateSeg);
} else if (patternSeg !== candidateSeg) {
return null;
}
}

return {params};
}

export default matchPathPattern;
export type {PatternMatch};
6 changes: 3 additions & 3 deletions src/libs/Navigation/helpers/getAdaptedStateFromPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,9 @@ function getMatchingFullScreenRoute(route: NavigationPartialRoute) {

// Handle dynamic routes: find the appropriate full screen route
if (route.path) {
const dynamicRouteSuffix = findMatchingDynamicSuffix(route.path);
if (dynamicRouteSuffix) {
const pathWithoutDynamicSuffix = getPathWithoutDynamicSuffix(route.path, dynamicRouteSuffix);
const suffixMatch = findMatchingDynamicSuffix(route.path);
if (suffixMatch) {
const pathWithoutDynamicSuffix = getPathWithoutDynamicSuffix(route.path, suffixMatch.actualSuffix, suffixMatch.pattern);

if (!pathWithoutDynamicSuffix) {
return undefined;
Expand Down
23 changes: 11 additions & 12 deletions src/libs/Navigation/helpers/getStateFromPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,37 +21,36 @@ function getStateFromPath(path: Route): PartialState<NavigationState> {
const redirectedPath = getRedirectedPath(normalizedPath);
const normalizedPathAfterRedirection = getMatchingNewRoute(redirectedPath) ?? redirectedPath;

const dynamicRouteSuffix = findMatchingDynamicSuffix(normalizedPathAfterRedirection);
if (dynamicRouteSuffix) {
const pathWithoutDynamicSuffix = getPathWithoutDynamicSuffix(normalizedPathAfterRedirection, dynamicRouteSuffix);
const suffixMatch = findMatchingDynamicSuffix(normalizedPathAfterRedirection);
if (suffixMatch) {
const {pattern, actualSuffix, pathParams} = suffixMatch;
const pathWithoutDynamicSuffix = getPathWithoutDynamicSuffix(normalizedPathAfterRedirection, actualSuffix, pattern);

type DynamicRouteKey = keyof typeof DYNAMIC_ROUTES;
const dynamicRouteKeys = Object.keys(DYNAMIC_ROUTES) as DynamicRouteKey[];

// Find the dynamic route key that matches the extracted suffix
const dynamicRoute: string = dynamicRouteKeys.find((key) => DYNAMIC_ROUTES[key].path === dynamicRouteSuffix) ?? '';
const dynamicRoute: string = dynamicRouteKeys.find((key) => DYNAMIC_ROUTES[key].path === pattern) ?? '';

// Get the currently focused route from the base path to check permissions
const focusedRoute = findFocusedRoute(getStateFromPath(pathWithoutDynamicSuffix) ?? {});
const entryScreens: Screen[] = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? [];

// Check if the focused route is allowed to access this dynamic route
if (focusedRoute?.name) {
if (entryScreens.includes(focusedRoute.name as Screen)) {
// Generate navigation state for the dynamic route
const dynamicRouteState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey);
const mergedParams = {
...(focusedRoute?.params as Record<string, unknown> | undefined),
...pathParams,
};
const dynamicRouteState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey, mergedParams);
return dynamicRouteState;
}

// Fallback to not found page so users can't land on dynamic suffix directly.
if (!pathWithoutDynamicSuffix) {
const state = {routes: [{name: SCREENS.NOT_FOUND, path: normalizedPathAfterRedirection}]};

return state;
}

// Log an error to quickly identify and add forgotten screens to the Dynamic Routes configuration
Log.warn(`[getStateFromPath.ts][DynamicRoute] Focused route ${focusedRoute.name} is not allowed to access dynamic route with suffix ${dynamicRouteSuffix}`);
Log.warn(`[getStateFromPath.ts][DynamicRoute] Focused route ${focusedRoute.name} is not allowed to access dynamic route with suffix ${pattern}`);
}
}

Expand Down
31 changes: 31 additions & 0 deletions tests/navigation/createDynamicRouteTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jest.mock('@src/ROUTES', () => ({
INVITE: {path: 'invite'},
FILTERS: {path: 'filters'},
ADDRESS_COUNTRY: {path: 'country', getRoute: (country: string) => `country?country=${country}`},
FLAG_COMMENT: {path: 'flag/:reportID/:reportActionID'},
MEMBER_DETAILS: {path: 'member-details/:accountID'},
},
}));

Expand Down Expand Up @@ -116,4 +118,33 @@ describe('createDynamicRoute', () => {

expect(() => createDynamicRoute(suffixWithQuery)).toThrow('[createDynamicRoute] Query param "country" exists in both base path and dynamic suffix. This is not allowed.');
});

// --- Parametric suffix tests ---
it('should append parametric suffix with single param to path', () => {
mockGetActiveRoute.mockReturnValue('r/123/members');

const result = createDynamicRoute('member-details/456');

expect(result).toBe('r/123/members/member-details/456');
});

it('should append parametric suffix with multiple params to path', () => {
mockGetActiveRoute.mockReturnValue('r/123');

const result = createDynamicRoute('flag/456/abc');

expect(result).toBe('r/123/flag/456/abc');
});

it('should append parametric suffix and preserve base query params', () => {
mockGetActiveRoute.mockReturnValue('search?q=test');

const result = createDynamicRoute('flag/456/abc');

expect(result).toBe('search/flag/456/abc?q=test');
});

it('should throw for suffix that does not match any parametric pattern', () => {
expect(() => createDynamicRoute('unknown/456/abc')).toThrow();
});
});
Loading
Loading