diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteQueryParams.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteQueryParams.ts new file mode 100644 index 0000000000000..a3effca8ba4fd --- /dev/null +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteQueryParams.ts @@ -0,0 +1,24 @@ +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +/** + * Returns the query parameter names that belong to a dynamic route suffix and should be + * stripped when removing that suffix from the path. Looks up the matching DYNAMIC_ROUTES + * config and returns its `queryParams` array if defined. + * + * @param dynamicSuffix - The dynamic route path segment (e.g., 'country') + * @returns The list of query param keys to strip, or undefined if no match or no queryParams config + */ +function getDynamicRouteQueryParams(dynamicSuffix: string): readonly string[] | undefined { + const keys = Object.keys(DYNAMIC_ROUTES) as Array; + const match = keys.find((key) => DYNAMIC_ROUTES[key].path === dynamicSuffix); + if (!match) { + return undefined; + } + const config = DYNAMIC_ROUTES[match]; + if (!('queryParams' in config)) { + return undefined; + } + return config.queryParams; +} + +export default getDynamicRouteQueryParams; diff --git a/src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts b/src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts index 2382b5cec186d..8a1ebe6f08678 100644 --- a/src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts +++ b/src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts @@ -1,30 +1,7 @@ import type {Route} from '@src/ROUTES'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import getDynamicRouteQueryParams from './getDynamicRouteQueryParams'; import splitPathAndQuery from './splitPathAndQuery'; -/** - * Returns the query parameter names that belong to a dynamic route suffix and should be - * stripped when removing that suffix from the path. Looks up the matching DYNAMIC_ROUTES - * config and returns its `queryParams` array if defined. - * - * @param dynamicSuffix - The dynamic route path segment (e.g., 'country') - * @returns The list of query param keys to strip, or undefined if no match or no queryParams config - * - * @private - Internal helper. Do not export or use outside this file. - */ -function getQueryParamsToStrip(dynamicSuffix: string): readonly string[] | undefined { - const keys = Object.keys(DYNAMIC_ROUTES) as Array; - const match = keys.find((key) => DYNAMIC_ROUTES[key].path === dynamicSuffix); - if (!match) { - return undefined; - } - const config = DYNAMIC_ROUTES[match]; - if (!('queryParams' in config)) { - return undefined; - } - return config.queryParams; -} - /** * Returns the path without a dynamic route suffix, stripping suffix-specific query parameters * (derived from the matching DYNAMIC_ROUTES.getRoute output) and preserving any remaining ones. @@ -41,7 +18,7 @@ function getPathWithoutDynamicSuffix(fullPath: string, dynamicSuffix: string): R return ''; } - const paramsToStrip = getQueryParamsToStrip(dynamicSuffix); + const paramsToStrip = getDynamicRouteQueryParams(dynamicSuffix); let filteredQuery = query; if (paramsToStrip?.length && query) { const params = new URLSearchParams(query); diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index 0362ce72a1489..0c4cdfc33ab82 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -1,10 +1,11 @@ import {findFocusedRoute, getPathFromState as RNGetPathFromState} from '@react-navigation/native'; import type {NavigationState, PartialState} from '@react-navigation/routers'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; -import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; +import {config, normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; import type {DynamicRouteSuffix} from '@src/ROUTES'; import type {Screen} from '@src/SCREENS'; +import getDynamicRouteQueryParams from './dynamicRoutesUtils/getDynamicRouteQueryParams'; import {dynamicRoutePaths} from './dynamicRoutesUtils/isDynamicRouteSuffix'; +import splitPathAndQuery from './dynamicRoutesUtils/splitPathAndQuery'; type State = NavigationState | Omit, 'stale'>; @@ -21,19 +22,151 @@ function isDynamicRouteScreen(screenName: Screen): boolean { return dynamicRoutePaths.has(screenPath as DynamicRouteSuffix); } -const getPathFromState = (state: State): string => { +/** + * Resolves a single path segment: if it's a `:param` placeholder, replaces it + * with the URL-encoded value from `params`; otherwise returns the segment as-is. + * Returns an empty string when a param value is missing or not a string/number. + * + * @private - Internal helper. Do not export or use outside this file. + */ +function resolveSegment(segment: string, params: Record | undefined): string { + if (segment.startsWith(':')) { + const paramName = segment.endsWith('?') ? segment.slice(1, -1) : segment.slice(1); + const value = params?.[paramName]; + + if (typeof value === 'string' || typeof value === 'number') { + return encodeURIComponent(String(value)); + } + + return ''; + } + return segment; +} + +/** + * Builds a concrete URL suffix from a dynamic route pattern by replacing `:param` + * placeholders with actual values and appending configured query parameters. + * + * @param pattern - The route path pattern (e.g., 'flag/:reportID/:reportActionID' or 'country') + * @param params - Route params to fill placeholders and query values from + * @returns The resolved suffix string (e.g., 'flag/456/abc' or 'country?country=US') + * + * @private - Internal helper. Do not export or use outside this file. + */ +function buildSuffixFromPattern(pattern: string, params: Record | undefined): string { + const pathPart = pattern + .split('/') + .map((segment) => resolveSegment(segment, params)) + // filter(Boolean) is used to remove empty segments + .filter(Boolean) + .join('/'); + + const queryParamKeys = getDynamicRouteQueryParams(pattern); + if (queryParamKeys && queryParamKeys.length > 0 && params) { + const queryParts: string[] = []; + for (const key of queryParamKeys) { + const value = params[key]; + if ((typeof value === 'string' || typeof value === 'number') && value !== '') { + queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + } + if (queryParts.length > 0) { + return `${pathPart}?${queryParts.join('&')}`; + } + } + + return pathPart; +} + +/** + * Pops the deepest focused route from a navigation state tree. + * Returns the reduced state, or undefined if the tree becomes empty. + * + * @param state - The navigation state tree to pop from + * @returns The reduced state, or undefined if the tree becomes empty + * + * @private - Internal helper. Do not export or use outside this file. + */ +function popFocusedRoute(state: State): State | undefined { + const index = state.index ?? state.routes.length - 1; + const focusedRoute = state.routes[index]; + + // no focused route exists at this level - nothing to pop. + if (!focusedRoute) { + return undefined; + } + + // the focused route has nested state — try to pop from deeper levels first. + if (focusedRoute.state) { + const nestedResult = popFocusedRoute(focusedRoute.state as State); + + // A deeper route was successfully popped - rebuild the current level with the updated nested state. + if (nestedResult) { + const newRoutes = [...state.routes] as typeof state.routes; + // @ts-expect-error -- we're rebuilding a structurally identical route with updated nested state + newRoutes[index] = {...focusedRoute, state: nestedResult}; + return {...state, routes: newRoutes, index} as State; + } + } + + // remove the focused route itself if siblings remain. + if (state.routes.length > 1) { + const newRoutes = state.routes.filter((_, i) => i !== index); + return {...state, routes: newRoutes, index: newRoutes.length - 1} as State; + } + + // Only one route at this level and nothing deeper to pop — signal the parent to remove this level entirely. + return undefined; +} + +/** + * Builds a URL path for a dynamic route screen that has no `path` in state. + * Recursively peels off dynamic suffixes and resolves the base path underneath. + * + * @param state - The navigation state tree to build the path from + * @returns The resolved path for the focused dynamic route screen + * + * @private - Internal helper. Do not export or use outside this file. + */ +function getPathFromStateWithDynamicRoute(state: State): string { const focusedRoute = findFocusedRoute(state); const screenName = focusedRoute?.name ?? ''; + const suffixPattern = normalizedConfigs[screenName as Screen]?.path; + + if (!suffixPattern) { + return RNGetPathFromState(state, config); + } + + const actualSuffix = buildSuffixFromPattern(suffixPattern, focusedRoute?.params as Record | undefined); + const reducedState = popFocusedRoute(state); - // Handle dynamic route screens that require special path that is placed in state - if (isDynamicRouteScreen(screenName as Screen) && focusedRoute?.path) { - return focusedRoute.path; + if (!reducedState) { + return `/${actualSuffix}`; } - // For regular routes, use React Navigation's default path generation - const path = RNGetPathFromState(state, linkingConfig.config); + const basePath = getPathFromState(reducedState); + const [basePathWithoutQuery, baseQuery] = splitPathAndQuery(basePath); + const [suffixPath, suffixQuery] = splitPathAndQuery(actualSuffix); - return path; -}; + const mergedParams = new URLSearchParams(baseQuery ?? ''); + const suffixParams = new URLSearchParams(suffixQuery ?? ''); + for (const [key, value] of suffixParams) { + mergedParams.set(key, value); + } + const queryString = mergedParams.toString(); + + return `${basePathWithoutQuery}/${suffixPath}${queryString ? `?${queryString}` : ''}`; +} + +function getPathFromState(state: State): string { + const focusedRoute = findFocusedRoute(state); + const screenName = focusedRoute?.name ?? ''; + + if (isDynamicRouteScreen(screenName as Screen)) { + return focusedRoute?.path ?? getPathFromStateWithDynamicRoute(state); + } + + return RNGetPathFromState(state, config); +} export default getPathFromState; diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index 88fedac311ee9..63126c40e1c72 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -1,7 +1,6 @@ import {getActionFromState} from '@react-navigation/core'; import type {NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import {findFocusedRoute, StackActions} from '@react-navigation/native'; -import findMatchingDynamicSuffix from '@libs/Navigation/helpers/dynamicRoutesUtils/findMatchingDynamicSuffix'; import {getMatchingFullScreenRoute, isFullScreenName} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; @@ -152,12 +151,10 @@ export default function linkTo(navigation: NavigationContainerRef = { getStateFromPath: getAdaptedStateFromPath, + getPathFromState, prefixes, config, subscribe, diff --git a/tests/navigation/getPathFromStateTests.ts b/tests/navigation/getPathFromStateTests.ts index 8b61140c46351..626f49bd501af 100644 --- a/tests/navigation/getPathFromStateTests.ts +++ b/tests/navigation/getPathFromStateTests.ts @@ -8,6 +8,7 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('@libs/Navigation/linkingConfig/config', () => ({ + config: {}, normalizedConfigs: { TestDynamicScreen: { path: 'test-dynamic', @@ -15,6 +16,24 @@ jest.mock('@libs/Navigation/linkingConfig/config', () => ({ StandardScreen: { path: 'standard', }, + VerifyAccountScreen: { + path: 'verify-account', + }, + CountryScreen: { + path: 'country', + }, + FlagScreen: { + path: 'flag/:reportID/:reportActionID', + }, + ConstantPickerScreen: { + path: 'constant-picker', + }, + WalletScreen: { + path: 'settings/wallet', + }, + ReportScreen: { + path: 'r/:reportID', + }, }, })); @@ -29,9 +48,55 @@ jest.mock('@src/ROUTES', () => ({ TEST_DYNAMIC: { path: 'test-dynamic', }, + VERIFY_ACCOUNT: { + path: 'verify-account', + }, + ADDRESS_COUNTRY: { + path: 'country', + queryParams: ['country'], + }, + FLAG: { + path: 'flag/:reportID/:reportActionID', + }, + CONSTANT_PICKER: { + path: 'constant-picker', + queryParams: ['formType', 'fieldName', 'fieldValue'], + }, }, })); +type RouteEntry = { + name: string; + params?: Record; + path?: string; + state?: {routes: RouteEntry[]; index: number}; +}; + +type TestState = { + routes: RouteEntry[]; + index: number; +}; + +function buildState(routes: RouteEntry[], index?: number): TestState { + return { + routes, + index: index ?? routes.length - 1, + } as TestState; +} + +function realFindFocusedRoute(state: TestState | RouteEntry['state']): RouteEntry | undefined { + let current: TestState | RouteEntry['state'] = state; + while (current?.routes?.[current.index ?? 0]?.state != null) { + current = current.routes[current.index ?? 0].state; + } + return current?.routes?.[current?.index ?? 0]; +} + +const staticBasePaths: Record) => string> = { + WalletScreen: () => '/settings/wallet', + ReportScreen: (params) => `/r/${(params?.reportID as string) ?? ''}`, +}; + describe('getPathFromState', () => { const mockFindFocusedRoute = findFocusedRoute as jest.Mock; const mockRNGetPathFromState = RNGetPathFromState as jest.Mock; @@ -55,21 +120,6 @@ describe('getPathFromState', () => { expect(mockRNGetPathFromState).not.toHaveBeenCalled(); }); - it('should fall back to RN getPathFromState for dynamic screens when path is MISSING', () => { - const state = {} as PartialState; - const expectedPath = '/generated/path'; - - mockFindFocusedRoute.mockReturnValue({ - name: 'TestDynamicScreen', - }); - mockRNGetPathFromState.mockReturnValue(expectedPath); - - const result = getPathFromState(state); - - expect(result).toBe(expectedPath); - expect(mockRNGetPathFromState).toHaveBeenCalledWith(state, expect.anything()); - }); - it('should use RN getPathFromState for standard screens', () => { const state = {} as PartialState; const expectedPath = '/standard/path'; @@ -95,4 +145,167 @@ describe('getPathFromState', () => { expect(result).toBe('/fallback'); expect(mockRNGetPathFromState).toHaveBeenCalled(); }); + + describe('dynamic route fallback (no path in state)', () => { + beforeEach(() => { + mockFindFocusedRoute.mockImplementation(realFindFocusedRoute); + + mockRNGetPathFromState.mockImplementation((s: TestState) => { + const route = realFindFocusedRoute(s); + const builder = staticBasePaths[route?.name ?? '']; + return builder ? builder(route?.params) : '/unknown'; + }); + }); + + it('simple suffix (no path/query params)', () => { + const state = buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account'); + }); + + it('suffix with path params', () => { + const state = buildState([ + {name: 'ReportScreen', params: {reportID: '123'}}, + {name: 'FlagScreen', params: {reportID: '456', reportActionID: 'abc'}}, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/r/123/flag/456/abc'); + }); + + it('suffix with query params', () => { + const state = buildState([{name: 'WalletScreen'}, {name: 'CountryScreen', params: {country: 'PL'}}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/country?country=PL'); + }); + + it('suffix with multiple query params', () => { + const state = buildState([ + {name: 'ReportScreen', params: {reportID: '123'}}, + {name: 'ConstantPickerScreen', params: {formType: 'report', fieldName: 'status', fieldValue: 'open', reportID: '123'}}, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/r/123/constant-picker?formType=report&fieldName=status&fieldValue=open'); + }); + + it('suffix with query params config but params missing', () => { + const state = buildState([{name: 'WalletScreen'}, {name: 'CountryScreen', params: {}}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/country'); + }); + + it('suffix with partial query params (some present, some missing)', () => { + const state = buildState([ + {name: 'ReportScreen', params: {reportID: '123'}}, + {name: 'ConstantPickerScreen', params: {formType: 'report', fieldName: 'status'}}, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/r/123/constant-picker?formType=report&fieldName=status'); + }); + + it('inner dynamic suffix has path, outer has query params', () => { + const state = buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen', path: '/settings/wallet/verify-account'}, {name: 'CountryScreen', params: {country: 'US'}}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account/country?country=US'); + }); + + it('inner dynamic suffix has path with path params, outer is simple', () => { + const state = buildState([{name: 'ReportScreen', params: {reportID: '123'}}, {name: 'FlagScreen', path: '/r/123/flag/456/abc'}, {name: 'VerifyAccountScreen'}]); + + expect(getPathFromState(state as PartialState)).toBe('/r/123/flag/456/abc/verify-account'); + }); + + it('inner dynamic suffix has path with query params, outer appends suffix (inner query params stripped)', () => { + const state = buildState([ + {name: 'WalletScreen'}, + {name: 'CountryScreen', path: '/settings/wallet/country?country=US'}, + {name: 'ConstantPickerScreen', params: {formType: 'report', fieldName: 'status', fieldValue: 'open'}}, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/country/constant-picker?country=US&formType=report&fieldName=status&fieldValue=open'); + }); + + it('two simple dynamic suffixes, no params on first', () => { + const state = buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}, {name: 'CountryScreen', params: {country: 'US'}}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account/country?country=US'); + }); + + it('path params + query params stacking', () => { + const state = buildState([ + {name: 'ReportScreen', params: {reportID: '123'}}, + {name: 'FlagScreen', params: {reportID: '456', reportActionID: 'abc'}}, + {name: 'CountryScreen', params: {country: 'US'}}, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/r/123/flag/456/abc/country?country=US'); + }); + + it('three stacked dynamic suffixes, none with path', () => { + const state = buildState([ + {name: 'WalletScreen'}, + {name: 'VerifyAccountScreen'}, + {name: 'CountryScreen', params: {country: 'US'}}, + {name: 'ConstantPickerScreen', params: {formType: 'report', fieldName: 'x'}}, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account/country/constant-picker?country=US&formType=report&fieldName=x'); + }); + + it('dynamic screen inside a nested navigator', () => { + const state = buildState([ + { + name: 'RHPNavigator', + state: buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}]), + }, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account'); + }); + + it('two dynamic suffixes inside nested navigator', () => { + const state = buildState([ + { + name: 'RHPNavigator', + state: buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}, {name: 'CountryScreen', params: {country: 'PL'}}]), + }, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account/country?country=PL'); + }); + + it('only dynamic route, no base (state has single route)', () => { + const state = buildState([{name: 'VerifyAccountScreen'}]); + + expect(getPathFromState(state as PartialState)).toBe('/verify-account'); + }); + + it('trailing slash normalization from base path', () => { + mockRNGetPathFromState.mockReturnValue('/settings/wallet/'); + + const state = buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account'); + }); + + it('path params with special characters are encoded', () => { + const state = buildState([ + {name: 'ReportScreen', params: {reportID: '123'}}, + {name: 'FlagScreen', params: {reportID: 'a/b', reportActionID: 'c&d'}}, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/r/123/flag/a%2Fb/c%26d'); + }); + + it('query param values with special characters are encoded', () => { + const state = buildState([{name: 'WalletScreen'}, {name: 'CountryScreen', params: {country: 'a&b=c'}}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/country?country=a%26b%3Dc'); + }); + + it('dynamic route with no params at all (params undefined)', () => { + const state = buildState([{name: 'WalletScreen'}, {name: 'CountryScreen'}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/country'); + }); + }); });