From d926cf762bfde44746fcdc1c356934f596835ec5 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 12 Mar 2026 14:32:30 +0100 Subject: [PATCH 1/3] add parameters propagating to dynamic routes state --- .../getStateForDynamicRoute.ts | 9 +++--- .../Navigation/helpers/getStateFromPath.ts | 2 +- .../getStateForDynamicRouteTests.ts | 30 +++++++++++++++++++ tests/navigation/getStateFromPathTests.ts | 5 ++-- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts index ce35dc567c20a..4ac21c3aabf8c 100644 --- a/src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts @@ -6,7 +6,7 @@ import splitPathAndQuery from './splitPathAndQuery'; type LeafRoute = { name: string; path: string; - params?: Record; + params?: Record; }; type NestedRoute = { @@ -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) { const routeConfig = getRouteNamesForDynamicRoute(DYNAMIC_ROUTES[dynamicRouteName].path); const [, query] = splitPathAndQuery(path); const params = getParamsFromQuery(query); @@ -67,12 +67,13 @@ 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; return { name: currentRoute ?? '', path, - params, + ...(mergedParams ? {params: mergedParams} : {}), }; } diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index 92119fdbf2281..2c4d7a11dcce2 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -39,7 +39,7 @@ function getStateFromPath(path: Route): PartialState { 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 dynamicRouteState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey, focusedRoute?.params as Record | undefined); return dynamicRouteState; } diff --git a/tests/navigation/getStateForDynamicRouteTests.ts b/tests/navigation/getStateForDynamicRouteTests.ts index cf3dc7c588f0e..9e2669201418f 100644 --- a/tests/navigation/getStateForDynamicRouteTests.ts +++ b/tests/navigation/getStateForDynamicRouteTests.ts @@ -31,6 +31,7 @@ jest.mock('@src/ROUTES', () => ({ type LeafRoute = { name: string; path: string; + params?: Record; }; type NestedRoute = { @@ -111,4 +112,33 @@ describe('getStateForDynamicRoute', () => { expect(state?.index).toBe(0); expect(Array.isArray(state?.routes)).toBe(true); }); + + it('should inherit parent route params on the leaf node', () => { + const path = '/r/12345/settings/test-path'; + const parentParams = {reportID: '12345'}; + const result = getStateForDynamicRoute(path, KEY_TEST as unknown as keyof typeof DYNAMIC_ROUTES, parentParams); + + const rootRoute = result.routes.at(0) as NestedRoute | undefined; + const leafRoute = rootRoute?.state.routes.at(0) as LeafRoute | undefined; + expect(leafRoute?.params).toEqual(parentParams); + }); + + it('should not include params on the leaf node when neither parentRouteParams nor query params are provided', () => { + const path = '/some/path/test-path'; + const result = getStateForDynamicRoute(path, KEY_TEST as unknown as keyof typeof DYNAMIC_ROUTES); + + const rootRoute = result.routes.at(0) as NestedRoute | undefined; + const leafRoute = rootRoute?.state.routes.at(0) as LeafRoute | undefined; + expect(leafRoute?.params).toBeUndefined(); + }); + + it('should merge parent route params with query params', () => { + const path = '/r/12345/settings/test-path?country=US'; + const parentParams = {reportID: '12345'}; + const result = getStateForDynamicRoute(path, KEY_TEST as unknown as keyof typeof DYNAMIC_ROUTES, parentParams); + + const rootRoute = result.routes.at(0) as NestedRoute | undefined; + const leafRoute = rootRoute?.state.routes.at(0) as LeafRoute | undefined; + expect(leafRoute?.params).toEqual({reportID: '12345', country: 'US'}); + }); }); diff --git a/tests/navigation/getStateFromPathTests.ts b/tests/navigation/getStateFromPathTests.ts index ffa5cb2ace840..5808b6f164e4d 100644 --- a/tests/navigation/getStateFromPathTests.ts +++ b/tests/navigation/getStateFromPathTests.ts @@ -57,9 +57,10 @@ describe('getStateFromPath', () => { it('should generate dynamic state when authorized screen is focused', () => { const fullPath = '/settings/wallet/verify-account'; const baseRouteState = {routes: [{name: 'Wallet'}]}; + const focusedRouteParams = {walletID: '456'}; mockRNGetStateFromPath.mockReturnValue(baseRouteState); - mockFindFocusedRoute.mockReturnValue({name: 'Wallet'}); + mockFindFocusedRoute.mockReturnValue({name: 'Wallet', params: focusedRouteParams}); const expectedDynamicState = {routes: [{name: 'DynamicRoot'}]}; mockGetStateForDynamicRoute.mockReturnValue(expectedDynamicState); @@ -67,7 +68,7 @@ describe('getStateFromPath', () => { const result = getStateFromPath(fullPath as unknown as Route); expect(result).toBe(expectedDynamicState); - expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith(fullPath, 'VERIFY_ACCOUNT'); + expect(mockGetStateForDynamicRoute).toHaveBeenCalledWith(fullPath, 'VERIFY_ACCOUNT', focusedRouteParams); }); it('should fallback to standard RN parsing if focused screen is NOT authorized for dynamic route', () => { From 961418f5e19283ed7f8cfb2ecc991fbab877f53a Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 12 Mar 2026 16:33:52 +0100 Subject: [PATCH 2/3] cleanup --- .../helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts index 4ac21c3aabf8c..32531ade90f55 100644 --- a/src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute.ts @@ -70,10 +70,11 @@ function getStateForDynamicRoute(path: string, dynamicRouteName: keyof typeof DY // 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, - ...(mergedParams ? {params: mergedParams} : {}), + ...paramsSpread, }; } From acbad611ae28741f5168611a419613781f5e70e7 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Thu, 12 Mar 2026 18:36:53 +0100 Subject: [PATCH 3/3] add initial logic for path parameters + tests --- src/hooks/useDynamicBackPath.ts | 30 +++++-- .../findMatchingDynamicSuffix.ts | 39 ++++++-- .../getPathWithoutDynamicSuffix.ts | 4 +- .../isDynamicRouteSuffix.ts | 8 +- .../dynamicRoutesUtils/matchPathPattern.ts | 39 ++++++++ .../helpers/getAdaptedStateFromPath.ts | 6 +- .../Navigation/helpers/getStateFromPath.ts | 23 +++-- tests/navigation/createDynamicRouteTests.ts | 31 +++++++ .../findMatchingDynamicSuffixTests.ts | 82 +++++++++++++++-- .../getPathWithoutDynamicSuffixTests.ts | 31 +++++++ tests/navigation/isDynamicRouteSuffixTests.ts | 50 +++++++++++ tests/navigation/matchPathPatternTests.ts | 90 +++++++++++++++++++ tests/navigation/useDynamicBackPathTests.ts | 46 ++++++++++ 13 files changed, 440 insertions(+), 39 deletions(-) create mode 100644 src/libs/Navigation/helpers/dynamicRoutesUtils/matchPathPattern.ts create mode 100644 tests/navigation/isDynamicRouteSuffixTests.ts create mode 100644 tests/navigation/matchPathPatternTests.ts diff --git a/src/hooks/useDynamicBackPath.ts b/src/hooks/useDynamicBackPath.ts index b2a33637cf7e3..a35a79578bad7 100644 --- a/src/hooks/useDynamicBackPath.ts +++ b/src/hooks/useDynamicBackPath.ts @@ -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'; @@ -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) => { @@ -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; } diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/findMatchingDynamicSuffix.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/findMatchingDynamicSuffix.ts index 93cf001223cb1..a47d4edec3f36 100644 --- a/src/libs/Navigation/helpers/dynamicRoutesUtils/findMatchingDynamicSuffix.ts +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/findMatchingDynamicSuffix.ts @@ -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; +}; + +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; @@ -20,8 +32,16 @@ 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}; + } } } @@ -29,3 +49,4 @@ function findMatchingDynamicSuffix(path = ''): string | undefined { } export default findMatchingDynamicSuffix; +export type {DynamicSuffixMatch}; diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts index 2382b5cec186d..3ffc9d8a759e0 100644 --- a/src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts @@ -33,7 +33,7 @@ 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)) ?? ''; @@ -41,7 +41,7 @@ function getPathWithoutDynamicSuffix(fullPath: string, dynamicSuffix: string): R return ''; } - const paramsToStrip = getQueryParamsToStrip(dynamicSuffix); + const paramsToStrip = getQueryParamsToStrip(patternSuffix ?? dynamicSuffix); let filteredQuery = query; if (paramsToStrip?.length && query) { const params = new URLSearchParams(query); diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteSuffix.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteSuffix.ts index 4d2be0f86167b..30bf067b2c45f 100644 --- a/src/libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteSuffix.ts +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteSuffix.ts @@ -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(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}; diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/matchPathPattern.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/matchPathPattern.ts new file mode 100644 index 0000000000000..dda6851332d7d --- /dev/null +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/matchPathPattern.ts @@ -0,0 +1,39 @@ +type PatternMatch = { + params: Record; +}; + +/** + * 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 = {}; + + 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}; diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index 930862e77ce54..7ff65775e7fbe 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -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; diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index 2c4d7a11dcce2..c05cc1b1986f5 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -21,37 +21,36 @@ function getStateFromPath(path: Route): PartialState { 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, focusedRoute?.params as Record | undefined); + const mergedParams = { + ...(focusedRoute?.params as Record | 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}`); } } diff --git a/tests/navigation/createDynamicRouteTests.ts b/tests/navigation/createDynamicRouteTests.ts index 9acbfff659729..0fe1b26045c13 100644 --- a/tests/navigation/createDynamicRouteTests.ts +++ b/tests/navigation/createDynamicRouteTests.ts @@ -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'}, }, })); @@ -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(); + }); }); diff --git a/tests/navigation/findMatchingDynamicSuffixTests.ts b/tests/navigation/findMatchingDynamicSuffixTests.ts index 6baaaf187d4cb..5bb07341ba897 100644 --- a/tests/navigation/findMatchingDynamicSuffixTests.ts +++ b/tests/navigation/findMatchingDynamicSuffixTests.ts @@ -1,12 +1,31 @@ import findMatchingDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/findMatchingDynamicSuffix'; +jest.mock('@src/ROUTES', () => ({ + DYNAMIC_ROUTES: { + VERIFY_ACCOUNT: {path: 'verify-account'}, + ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: {path: 'add-bank-account/verify-account'}, + COUNTRY: {path: 'country', queryParams: {country: true}}, + FLAG_COMMENT: {path: 'flag/:reportID/:reportActionID'}, + MEMBER_DETAILS: {path: 'member-details/:accountID'}, + }, +})); + describe('findMatchingDynamicSuffix', () => { + // --- Existing tests adapted to new return type --- it('should match a single-segment dynamic suffix', () => { - expect(findMatchingDynamicSuffix('settings/wallet/verify-account')).toBe('verify-account'); + expect(findMatchingDynamicSuffix('settings/wallet/verify-account')).toEqual({ + pattern: 'verify-account', + actualSuffix: 'verify-account', + pathParams: {}, + }); }); it('should match when the path has a leading slash', () => { - expect(findMatchingDynamicSuffix('/settings/wallet/verify-account')).toBe('verify-account'); + expect(findMatchingDynamicSuffix('/settings/wallet/verify-account')).toEqual({ + pattern: 'verify-account', + actualSuffix: 'verify-account', + pathParams: {}, + }); }); it('should return undefined for a path with no matching suffix', () => { @@ -22,11 +41,19 @@ describe('findMatchingDynamicSuffix', () => { }); it('should ignore query parameters when matching', () => { - expect(findMatchingDynamicSuffix('settings/wallet/verify-account?sortBy=date')).toBe('verify-account'); + expect(findMatchingDynamicSuffix('settings/wallet/verify-account?sortBy=date')).toEqual({ + pattern: 'verify-account', + actualSuffix: 'verify-account', + pathParams: {}, + }); }); it('should handle trailing slashes', () => { - expect(findMatchingDynamicSuffix('settings/wallet/verify-account/')).toBe('verify-account'); + expect(findMatchingDynamicSuffix('settings/wallet/verify-account/')).toEqual({ + pattern: 'verify-account', + actualSuffix: 'verify-account', + pathParams: {}, + }); }); it('should not match a suffix that appears in the middle of the path', () => { @@ -34,6 +61,51 @@ describe('findMatchingDynamicSuffix', () => { }); it('should match a suffix when path has suffix-specific query params', () => { - expect(findMatchingDynamicSuffix('settings/profile/address/country?country=US')).toBe('country'); + expect(findMatchingDynamicSuffix('settings/profile/address/country?country=US')).toEqual({ + pattern: 'country', + actualSuffix: 'country', + pathParams: {}, + }); + }); + + it('should prefer longer multi-segment static match over shorter', () => { + expect(findMatchingDynamicSuffix('/settings/wallet/add-bank-account/verify-account')).toEqual({ + pattern: 'add-bank-account/verify-account', + actualSuffix: 'add-bank-account/verify-account', + pathParams: {}, + }); + }); + + // --- NEW parametric tests --- + it('should match parametric suffix and extract params', () => { + expect(findMatchingDynamicSuffix('/r/123/flag/456/abc')).toEqual({ + pattern: 'flag/:reportID/:reportActionID', + actualSuffix: 'flag/456/abc', + pathParams: {reportID: '456', reportActionID: 'abc'}, + }); + }); + + it('should match single-param suffix', () => { + expect(findMatchingDynamicSuffix('/r/123/members/member-details/456')).toEqual({ + pattern: 'member-details/:accountID', + actualSuffix: 'member-details/456', + pathParams: {accountID: '456'}, + }); + }); + + it('should not match parametric suffix in the middle of path', () => { + expect(findMatchingDynamicSuffix('/flag/123/abc/settings/wallet')).toBeUndefined(); + }); + + it('should return undefined when no parametric pattern matches', () => { + expect(findMatchingDynamicSuffix('/r/123/unknown/456/abc')).toBeUndefined(); + }); + + it('should handle query params alongside parametric suffix', () => { + expect(findMatchingDynamicSuffix('/r/123/flag/456/abc?tab=details')).toEqual({ + pattern: 'flag/:reportID/:reportActionID', + actualSuffix: 'flag/456/abc', + pathParams: {reportID: '456', reportActionID: 'abc'}, + }); }); }); diff --git a/tests/navigation/getPathWithoutDynamicSuffixTests.ts b/tests/navigation/getPathWithoutDynamicSuffixTests.ts index f0b22e6a0ed43..c893923b7e44e 100644 --- a/tests/navigation/getPathWithoutDynamicSuffixTests.ts +++ b/tests/navigation/getPathWithoutDynamicSuffixTests.ts @@ -48,4 +48,35 @@ describe('getPathWithoutDynamicSuffix', () => { expect(result).toBe('/settings/profile/address?baseParam=1'); }); + + // --- Parametric suffix tests --- + it('should strip parametric suffix by actual value length', () => { + const result = getPathWithoutDynamicSuffix('/r/123/flag/456/abc', 'flag/456/abc'); + + expect(result).toBe('/r/123'); + }); + + it('should strip single-param suffix', () => { + const result = getPathWithoutDynamicSuffix('/r/123/members/member-details/456', 'member-details/456'); + + expect(result).toBe('/r/123/members'); + }); + + it('should strip parametric suffix and preserve query params', () => { + const result = getPathWithoutDynamicSuffix('/r/123/flag/456/abc?tab=details', 'flag/456/abc'); + + expect(result).toBe('/r/123?tab=details'); + }); + + it('should strip suffix-specific query params using pattern suffix', () => { + const result = getPathWithoutDynamicSuffix('/settings/profile/address/country?country=US&otherParam=1', 'country', 'country'); + + expect(result).toBe('/settings/profile/address?otherParam=1'); + }); + + it('should return empty when parametric suffix covers entire path', () => { + const result = getPathWithoutDynamicSuffix('/flag/123/abc', 'flag/123/abc'); + + expect(result).toBe(''); + }); }); diff --git a/tests/navigation/isDynamicRouteSuffixTests.ts b/tests/navigation/isDynamicRouteSuffixTests.ts new file mode 100644 index 0000000000000..67b16b1829b73 --- /dev/null +++ b/tests/navigation/isDynamicRouteSuffixTests.ts @@ -0,0 +1,50 @@ +import isDynamicRouteSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteSuffix'; + +jest.mock('@src/ROUTES', () => ({ + DYNAMIC_ROUTES: { + VERIFY_ACCOUNT: {path: 'verify-account'}, + ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: {path: 'add-bank-account/verify-account'}, + FLAG_COMMENT: {path: 'flag/:reportID/:reportActionID'}, + MEMBER_DETAILS: {path: 'member-details/:accountID'}, + }, +})); + +describe('isDynamicRouteSuffix', () => { + // --- Static (existing behavior) --- + it('should return true for exact static suffix', () => { + expect(isDynamicRouteSuffix('verify-account')).toBe(true); + }); + + it('should return true for exact static multi-segment suffix', () => { + expect(isDynamicRouteSuffix('add-bank-account/verify-account')).toBe(true); + }); + + it('should return false for unknown static suffix', () => { + expect(isDynamicRouteSuffix('unknown-page')).toBe(false); + }); + + // --- Parametric (new behavior) --- + it('should return true for suffix matching single-param pattern', () => { + expect(isDynamicRouteSuffix('member-details/456')).toBe(true); + }); + + it('should return true for suffix matching multi-param pattern', () => { + expect(isDynamicRouteSuffix('flag/123/abc')).toBe(true); + }); + + it('should return false for suffix with wrong static prefix', () => { + expect(isDynamicRouteSuffix('other/123/abc')).toBe(false); + }); + + it('should return false for suffix with wrong segment count', () => { + expect(isDynamicRouteSuffix('flag/123')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isDynamicRouteSuffix('')).toBe(false); + }); + + it('should return false for suffix with extra segments beyond pattern', () => { + expect(isDynamicRouteSuffix('flag/123/abc/extra')).toBe(false); + }); +}); diff --git a/tests/navigation/matchPathPatternTests.ts b/tests/navigation/matchPathPatternTests.ts new file mode 100644 index 0000000000000..b3a54e547ba72 --- /dev/null +++ b/tests/navigation/matchPathPatternTests.ts @@ -0,0 +1,90 @@ +import matchPathPattern from '@libs/Navigation/helpers/dynamicRoutesUtils/matchPathPattern'; + +describe('matchPathPattern', () => { + // --- Static patterns (no params) --- + it('should match identical static single-segment strings', () => { + expect(matchPathPattern('verify-account', 'verify-account')).toEqual({params: {}}); + }); + + it('should match identical static multi-segment strings', () => { + expect(matchPathPattern('add-bank-account/verify-account', 'add-bank-account/verify-account')).toEqual({params: {}}); + }); + + it('should return null for mismatched static strings', () => { + expect(matchPathPattern('verify-account', 'other-page')).toBeNull(); + }); + + // --- Single param --- + it('should match single param pattern and extract value', () => { + expect(matchPathPattern('123', ':reportID')).toEqual({params: {reportID: '123'}}); + }); + + it('should match pattern with static prefix + single param', () => { + expect(matchPathPattern('flag/abc', 'flag/:reportActionID')).toEqual({params: {reportActionID: 'abc'}}); + }); + + it('should match pattern with static prefix + param + static suffix', () => { + expect(matchPathPattern('members/456/edit', 'members/:accountID/edit')).toEqual({params: {accountID: '456'}}); + }); + + // --- Multiple params --- + it('should match pattern with two params', () => { + expect(matchPathPattern('flag/123/abc', 'flag/:reportID/:reportActionID')).toEqual({ + params: {reportID: '123', reportActionID: 'abc'}, + }); + }); + + it('should match pattern with three params', () => { + expect(matchPathPattern('a/1/b/2/c/3', 'a/:p1/b/:p2/c/:p3')).toEqual({ + params: {p1: '1', p2: '2', p3: '3'}, + }); + }); + + it('should match pattern with consecutive params (no static separators)', () => { + expect(matchPathPattern('123/abc/xyz', ':a/:b/:c')).toEqual({ + params: {a: '123', b: 'abc', c: 'xyz'}, + }); + }); + + // --- Segment count mismatch --- + it('should return null when candidate has more segments than pattern', () => { + expect(matchPathPattern('flag/123/abc/extra', 'flag/:reportID/:reportActionID')).toBeNull(); + }); + + it('should return null when candidate has fewer segments than pattern', () => { + expect(matchPathPattern('flag/123', 'flag/:reportID/:reportActionID')).toBeNull(); + }); + + it('should return null for empty candidate against non-empty pattern', () => { + expect(matchPathPattern('', 'flag/:id')).toBeNull(); + }); + + it('should return null for non-empty candidate against empty pattern', () => { + expect(matchPathPattern('flag/123', '')).toBeNull(); + }); + + // --- Static segment mismatch --- + it('should return null when static segment does not match', () => { + expect(matchPathPattern('other/123/abc', 'flag/:reportID/:reportActionID')).toBeNull(); + }); + + it('should return null when middle static segment does not match', () => { + expect(matchPathPattern('a/123/c/456', 'a/:p1/b/:p2')).toBeNull(); + }); + + // --- URL-encoded values --- + it('should decode URI-encoded param values', () => { + expect(matchPathPattern('flag/hello%20world', 'flag/:name')).toEqual({ + params: {name: 'hello world'}, + }); + }); + + // --- Edge cases --- + it('should match two empty strings', () => { + expect(matchPathPattern('', '')).toEqual({params: {}}); + }); + + it('should handle leading/trailing slashes in candidate', () => { + expect(matchPathPattern('/flag/123/', 'flag/:id')).toEqual({params: {id: '123'}}); + }); +}); diff --git a/tests/navigation/useDynamicBackPathTests.ts b/tests/navigation/useDynamicBackPathTests.ts index 99dacded768ff..18c25371dfeae 100644 --- a/tests/navigation/useDynamicBackPathTests.ts +++ b/tests/navigation/useDynamicBackPathTests.ts @@ -1,5 +1,6 @@ import {renderHook} from '@testing-library/react-native'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import type {DynamicRouteSuffix} from '@src/ROUTES'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; jest.mock('@hooks/useRootNavigationState', () => jest.fn()); @@ -12,6 +13,8 @@ jest.mock('@src/ROUTES', () => ({ VERIFY_ACCOUNT: {path: 'verify-account'}, CUSTOM_TEST_ROUTE: {path: 'custom-test-route'}, ADDRESS_COUNTRY: {path: 'country'}, + FLAG_COMMENT: {path: 'flag/:reportID/:reportActionID'}, + MEMBER_DETAILS: {path: 'member-details/:accountID'}, }, })); @@ -83,4 +86,47 @@ describe('useDynamicBackPath', () => { expect(result.current).toBe('settings/wallet'); }); + + const FLAG_COMMENT_PATH = 'flag/:reportID/:reportActionID' as DynamicRouteSuffix; + const MEMBER_DETAILS_PATH = 'member-details/:accountID' as DynamicRouteSuffix; + + it('should remove parametric suffix with single param', () => { + getPathFromStateMock.mockReturnValue('r/123/members/member-details/456'); + + const {result} = renderHook(() => useDynamicBackPath(MEMBER_DETAILS_PATH)); + + expect(result.current).toBe('r/123/members'); + }); + + it('should remove parametric suffix with multiple params', () => { + getPathFromStateMock.mockReturnValue('r/123/flag/456/abc'); + + const {result} = renderHook(() => useDynamicBackPath(FLAG_COMMENT_PATH)); + + expect(result.current).toBe('r/123'); + }); + + it('should NOT remove parametric suffix when segment values dont fill pattern', () => { + getPathFromStateMock.mockReturnValue('r/123/flag/456'); + + const {result} = renderHook(() => useDynamicBackPath(FLAG_COMMENT_PATH)); + + expect(result.current).toBe('r/123/flag/456'); + }); + + it('should preserve query params when removing parametric suffix', () => { + getPathFromStateMock.mockReturnValue('r/123/flag/456/abc?tab=details'); + + const {result} = renderHook(() => useDynamicBackPath(FLAG_COMMENT_PATH)); + + expect(result.current).toBe('r/123?tab=details'); + }); + + it('should NOT remove parametric suffix when static segment mismatches', () => { + getPathFromStateMock.mockReturnValue('r/123/other/456/abc'); + + const {result} = renderHook(() => useDynamicBackPath(FLAG_COMMENT_PATH)); + + expect(result.current).toBe('r/123/other/456/abc'); + }); });