Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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<keyof typeof DYNAMIC_ROUTES>;
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;
Original file line number Diff line number Diff line change
@@ -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<keyof typeof DYNAMIC_ROUTES>;
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.
Expand All @@ -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);
Expand Down
153 changes: 143 additions & 10 deletions src/libs/Navigation/helpers/getPathFromState.ts
Original file line number Diff line number Diff line change
@@ -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<PartialState<NavigationState>, 'stale'>;

Expand All @@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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;
5 changes: 1 addition & 4 deletions src/libs/Navigation/helpers/linkTo/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -152,12 +151,10 @@ export default function linkTo(navigation: NavigationContainerRef<RootNavigatorP
// If not, it will be replaced. This way, navigating between one attachment screen and another won't be added to the browser history.
// Report screen - Also a special case. If we are navigating to the report with same reportID we want to replace it (navigate will do that).
// This covers the case when we open a specific message in report (reportActionID).
// Dynamic routes - Keep NAVIGATE so that StackRouter preserves `path` on the route (PUSH explicitly sets path to undefined).
else if (
action.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE &&
!isNavigatingToAttachmentScreen(focusedRouteFromPath?.name) &&
!isNavigatingToReportWithSameReportID(currentFocusedRoute, focusedRouteFromPath) &&
!findMatchingDynamicSuffix(normalizedPath)
!isNavigatingToReportWithSameReportID(currentFocusedRoute, focusedRouteFromPath)
) {
// We want to PUSH by default to add entries to the browser history.
action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
Expand Down
2 changes: 2 additions & 0 deletions src/libs/Navigation/linkingConfig/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type {LinkingOptions} from '@react-navigation/native';
import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath';
import getPathFromState from '@libs/Navigation/helpers/getPathFromState';
import type {RootNavigatorParamList} from '@libs/Navigation/types';
import {config} from './config';
import prefixes from './prefixes';
import subscribe from './subscribe';

const linkingConfig: LinkingOptions<RootNavigatorParamList> = {
getStateFromPath: getAdaptedStateFromPath,
getPathFromState,
prefixes,
config,
subscribe,
Expand Down
Loading
Loading