From f220c9905e315e1c9d58a6966a48ac4f94472034 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Mon, 30 Mar 2026 16:32:50 +0200 Subject: [PATCH 1/9] add getPathForDynamicRoute logic and tests --- .../Navigation/helpers/getPathFromState.ts | 130 +++++++++- src/libs/Navigation/helpers/linkTo/index.ts | 5 +- src/libs/Navigation/linkingConfig/index.ts | 2 + tests/navigation/getPathFromStateTests.ts | 243 +++++++++++++++++- 4 files changed, 358 insertions(+), 22 deletions(-) diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index 0362ce72a1489..e64209c701345 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -3,8 +3,10 @@ import type {NavigationState, PartialState} from '@react-navigation/routers'; import {linkingConfig} from '@libs/Navigation/linkingConfig'; import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; import type {DynamicRouteSuffix} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Screen} from '@src/SCREENS'; import {dynamicRoutePaths} from './dynamicRoutesUtils/isDynamicRouteSuffix'; +import splitPathAndQuery from './dynamicRoutesUtils/splitPathAndQuery'; type State = NavigationState | Omit, 'stale'>; @@ -21,19 +23,131 @@ function isDynamicRouteScreen(screenName: Screen): boolean { return dynamicRoutePaths.has(screenPath as DynamicRouteSuffix); } -const getPathFromState = (state: State): string => { +function findQueryParamsForPattern(pattern: string): readonly string[] | undefined { + for (const entry of Object.values(DYNAMIC_ROUTES)) { + if (entry.path === pattern && 'queryParams' in entry) { + return entry.queryParams as readonly string[]; + } + } + return undefined; +} + +/** + * Reconstructs the actual URL suffix from a dynamic route pattern and route params. + * Fills :param placeholders with values from params, and appends query params + * based on the DYNAMIC_ROUTES[].queryParams config. + * + * Example: pattern 'flag/:reportID/:reportActionID' + params {reportID: '456', reportActionID: 'abc'} + * --> 'flag/456/abc' + * Example: pattern 'country' + params {country: 'US'} + queryParams config ['country'] + * --> 'country?country=US' + */ +function buildSuffixFromPattern(pattern: string, params: Record | undefined): string { + const pathPart = pattern + .split('/') + .map((segment) => { + 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; + }) + .join('/'); + + const queryParamKeys = findQueryParamsForPattern(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; +} + +/** + * Removes the deepest focused route from the state tree. + * Descends through routes[index] at each level until reaching a leaf route, + * then removes it. If removing the route empties the navigator, cascades + * removal upward by returning undefined to the parent. + */ +function popFocusedRoute(state: State): State | undefined { + const index = state.index ?? state.routes.length - 1; + const focusedRoute = state.routes[index]; + + if (!focusedRoute) { + return undefined; + } + + if (focusedRoute.state) { + const nestedResult = popFocusedRoute(focusedRoute.state as 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; + } + } + + if (state.routes.length > 1) { + const newRoutes = state.routes.filter((_, i) => i !== index); + return {...state, routes: newRoutes, index: newRoutes.length - 1} as State; + } + + return undefined; +} + +/** + * Fallback path builder for dynamic route screens that lack a `path` property. + * Peels off the dynamic suffix from the focused route, pops it from the state, + * and recursively calls getPathFromState on the reduced state. This naturally + * handles stacked dynamic routes (each recursion peels off one suffix). + */ +function getPathForDynamicRoute(state: State): string { const focusedRoute = findFocusedRoute(state); const screenName = focusedRoute?.name ?? ''; + const suffixPattern = normalizedConfigs[screenName as Screen]?.path; + + if (!suffixPattern) { + return RNGetPathFromState(state, linkingConfig.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] = splitPathAndQuery(basePath); - return path; -}; + return `${basePathWithoutQuery}/${actualSuffix}`; +} + +function getPathFromState(state: State): string { + const focusedRoute = findFocusedRoute(state); + const screenName = focusedRoute?.name ?? ''; + + if (isDynamicRouteScreen(screenName as Screen)) { + if (focusedRoute?.path) { + return focusedRoute.path; + } + return getPathForDynamicRoute(state); + } + + return RNGetPathFromState(state, linkingConfig.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: (state) => getPathFromState(state), prefixes, config, subscribe, diff --git a/tests/navigation/getPathFromStateTests.ts b/tests/navigation/getPathFromStateTests.ts index 8b61140c46351..071e388f9688f 100644 --- a/tests/navigation/getPathFromStateTests.ts +++ b/tests/navigation/getPathFromStateTests.ts @@ -15,6 +15,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 +47,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,19 +119,15 @@ 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'; + it('should use custom fallback for dynamic screens when path is MISSING', () => { + const state = buildState([{name: 'StandardScreen'}, {name: 'TestDynamicScreen'}]); - mockFindFocusedRoute.mockReturnValue({ - name: 'TestDynamicScreen', - }); - mockRNGetPathFromState.mockReturnValue(expectedPath); + mockFindFocusedRoute.mockImplementation(realFindFocusedRoute); + mockRNGetPathFromState.mockReturnValue('/standard'); - const result = getPathFromState(state); + const result = getPathFromState(state as PartialState); - expect(result).toBe(expectedPath); - expect(mockRNGetPathFromState).toHaveBeenCalledWith(state, expect.anything()); + expect(result).toBe('/standard/test-dynamic'); }); it('should use RN getPathFromState for standard screens', () => { @@ -95,4 +155,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('1a: simple suffix (no path/query params)', () => { + const state = buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}]); + + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account'); + }); + + it('1b: 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('1c: 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('1d: suffix with multiple query params', () => { + const state = buildState([ + {name: 'ReportScreen', params: {reportID: '123'}}, + {name: 'ConstantPickerScreen', params: {formType: 'report', fieldName: 'status', fieldValue: 'open'}}, + ]); + + expect(getPathFromState(state as PartialState)).toBe('/r/123/constant-picker?formType=report&fieldName=status&fieldValue=open'); + }); + + it('1e: 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('1f: 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('2a: inner 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('2b: inner 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('2c: inner 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?formType=report&fieldName=status&fieldValue=open'); + }); + + it('3a: 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('3b: 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('3c: 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?formType=report&fieldName=x'); + }); + + it('4a: 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('4b: 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('5a: only dynamic route, no base (state has single route)', () => { + const state = buildState([{name: 'VerifyAccountScreen'}]); + + expect(getPathFromState(state as PartialState)).toBe('/verify-account'); + }); + + it('5b: 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('5c: 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('5d: 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('5e: 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'); + }); + }); }); From cb269fc542753f10105779089da66f413a806910 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 31 Mar 2026 11:34:13 +0200 Subject: [PATCH 2/9] add comments and fix small bugs --- .../getDynamicRouteQueryParams.ts | 24 +++++++++ .../getPathWithoutDynamicSuffix.ts | 27 +--------- .../Navigation/helpers/getPathFromState.ts | 50 +++++++++---------- 3 files changed, 50 insertions(+), 51 deletions(-) create mode 100644 src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteQueryParams.ts 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 e64209c701345..b164e203d1afe 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -3,8 +3,8 @@ import type {NavigationState, PartialState} from '@react-navigation/routers'; import {linkingConfig} from '@libs/Navigation/linkingConfig'; import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; import type {DynamicRouteSuffix} from '@src/ROUTES'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Screen} from '@src/SCREENS'; +import getDynamicRouteQueryParams from './dynamicRoutesUtils/getDynamicRouteQueryParams'; import {dynamicRoutePaths} from './dynamicRoutesUtils/isDynamicRouteSuffix'; import splitPathAndQuery from './dynamicRoutesUtils/splitPathAndQuery'; @@ -23,24 +23,15 @@ function isDynamicRouteScreen(screenName: Screen): boolean { return dynamicRoutePaths.has(screenPath as DynamicRouteSuffix); } -function findQueryParamsForPattern(pattern: string): readonly string[] | undefined { - for (const entry of Object.values(DYNAMIC_ROUTES)) { - if (entry.path === pattern && 'queryParams' in entry) { - return entry.queryParams as readonly string[]; - } - } - return undefined; -} - /** - * Reconstructs the actual URL suffix from a dynamic route pattern and route params. - * Fills :param placeholders with values from params, and appends query params - * based on the DYNAMIC_ROUTES[].queryParams config. + * Builds a concrete URL suffix from a dynamic route pattern by replacing `:param` + * placeholders with actual values and appending configured query parameters. * - * Example: pattern 'flag/:reportID/:reportActionID' + params {reportID: '456', reportActionID: 'abc'} - * --> 'flag/456/abc' - * Example: pattern 'country' + params {country: 'US'} + queryParams config ['country'] - * --> 'country?country=US' + * @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 @@ -56,9 +47,10 @@ function buildSuffixFromPattern(pattern: string, params: Record } return segment; }) + .filter(Boolean) .join('/'); - const queryParamKeys = findQueryParamsForPattern(pattern); + const queryParamKeys = getDynamicRouteQueryParams(pattern); if (queryParamKeys && queryParamKeys.length > 0 && params) { const queryParts: string[] = []; for (const key of queryParamKeys) { @@ -76,10 +68,13 @@ function buildSuffixFromPattern(pattern: string, params: Record } /** - * Removes the deepest focused route from the state tree. - * Descends through routes[index] at each level until reaching a leaf route, - * then removes it. If removing the route empties the navigator, cascades - * removal upward by returning undefined to the parent. + * 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; @@ -109,10 +104,13 @@ function popFocusedRoute(state: State): State | undefined { } /** - * Fallback path builder for dynamic route screens that lack a `path` property. - * Peels off the dynamic suffix from the focused route, pops it from the state, - * and recursively calls getPathFromState on the reduced state. This naturally - * handles stacked dynamic routes (each recursion peels off one suffix). + * 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 getPathForDynamicRoute(state: State): string { const focusedRoute = findFocusedRoute(state); From 16d85f03d81a3a74f8e4c2cfee289c7289280595 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 31 Mar 2026 12:23:12 +0200 Subject: [PATCH 3/9] fixed getPathFromState --- .../Navigation/helpers/getPathFromState.ts | 12 +++- src/libs/Navigation/linkingConfig/index.ts | 2 +- tests/navigation/getPathFromStateTests.ts | 56 ++++++++----------- 3 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index b164e203d1afe..b3ac37364ba38 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -129,9 +129,17 @@ function getPathForDynamicRoute(state: State): string { } const basePath = getPathFromState(reducedState); - const [basePathWithoutQuery] = splitPathAndQuery(basePath); + const [basePathWithoutQuery, baseQuery] = splitPathAndQuery(basePath); + const [suffixPath, suffixQuery] = splitPathAndQuery(actualSuffix); - return `${basePathWithoutQuery}/${actualSuffix}`; + 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 { diff --git a/src/libs/Navigation/linkingConfig/index.ts b/src/libs/Navigation/linkingConfig/index.ts index 973b809f50797..203ce49b0e5bd 100644 --- a/src/libs/Navigation/linkingConfig/index.ts +++ b/src/libs/Navigation/linkingConfig/index.ts @@ -9,7 +9,7 @@ import subscribe from './subscribe'; const linkingConfig: LinkingOptions = { getStateFromPath: getAdaptedStateFromPath, - getPathFromState: (state) => getPathFromState(state), + getPathFromState, prefixes, config, subscribe, diff --git a/tests/navigation/getPathFromStateTests.ts b/tests/navigation/getPathFromStateTests.ts index 071e388f9688f..557804111eb5a 100644 --- a/tests/navigation/getPathFromStateTests.ts +++ b/tests/navigation/getPathFromStateTests.ts @@ -119,17 +119,6 @@ describe('getPathFromState', () => { expect(mockRNGetPathFromState).not.toHaveBeenCalled(); }); - it('should use custom fallback for dynamic screens when path is MISSING', () => { - const state = buildState([{name: 'StandardScreen'}, {name: 'TestDynamicScreen'}]); - - mockFindFocusedRoute.mockImplementation(realFindFocusedRoute); - mockRNGetPathFromState.mockReturnValue('/standard'); - - const result = getPathFromState(state as PartialState); - - expect(result).toBe('/standard/test-dynamic'); - }); - it('should use RN getPathFromState for standard screens', () => { const state = {} as PartialState; const expectedPath = '/standard/path'; @@ -167,13 +156,13 @@ describe('getPathFromState', () => { }); }); - it('1a: simple suffix (no path/query params)', () => { + 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('1b: suffix with path params', () => { + it('suffix with path params', () => { const state = buildState([ {name: 'ReportScreen', params: {reportID: '123'}}, {name: 'FlagScreen', params: {reportID: '456', reportActionID: 'abc'}}, @@ -182,28 +171,28 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/r/123/flag/456/abc'); }); - it('1c: suffix with query params', () => { + 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('1d: suffix with multiple query params', () => { + it('suffix with multiple query params', () => { const state = buildState([ {name: 'ReportScreen', params: {reportID: '123'}}, - {name: 'ConstantPickerScreen', params: {formType: 'report', fieldName: 'status', fieldValue: 'open'}}, + {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('1e: suffix with query params config but params missing', () => { + 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('1f: suffix with partial query params (some present, some missing)', () => { + 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'}}, @@ -212,35 +201,36 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/r/123/constant-picker?formType=report&fieldName=status'); }); - it('2a: inner has path, outer has query params', () => { + 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('2b: inner has path with path params, outer is simple', () => { + 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('2c: inner has path WITH query params, outer appends suffix (inner query params stripped)', () => { + 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?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'); }); + // no - it('3a: two simple dynamic suffixes, no params on first', () => { + 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('3b: path params + query params stacking', () => { + it('path params + query params stacking', () => { const state = buildState([ {name: 'ReportScreen', params: {reportID: '123'}}, {name: 'FlagScreen', params: {reportID: '456', reportActionID: 'abc'}}, @@ -250,7 +240,7 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/r/123/flag/456/abc/country?country=US'); }); - it('3c: three stacked dynamic suffixes, none with path', () => { + it('three stacked dynamic suffixes, none with path', () => { const state = buildState([ {name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}, @@ -258,10 +248,10 @@ describe('getPathFromState', () => { {name: 'ConstantPickerScreen', params: {formType: 'report', fieldName: 'x'}}, ]); - expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account/country/constant-picker?formType=report&fieldName=x'); + expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account/country/constant-picker?country=US&formType=report&fieldName=x'); }); - it('4a: dynamic screen inside a nested navigator', () => { + it('dynamic screen inside a nested navigator', () => { const state = buildState([ { name: 'RHPNavigator', @@ -272,7 +262,7 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account'); }); - it('4b: two dynamic suffixes inside nested navigator', () => { + it('two dynamic suffixes inside nested navigator', () => { const state = buildState([ { name: 'RHPNavigator', @@ -283,13 +273,13 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account/country?country=PL'); }); - it('5a: only dynamic route, no base (state has single route)', () => { + it('only dynamic route, no base (state has single route)', () => { const state = buildState([{name: 'VerifyAccountScreen'}]); expect(getPathFromState(state as PartialState)).toBe('/verify-account'); }); - it('5b: trailing slash normalization from base path', () => { + it('trailing slash normalization from base path', () => { mockRNGetPathFromState.mockReturnValue('/settings/wallet/'); const state = buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}]); @@ -297,7 +287,7 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account'); }); - it('5c: path params with special characters are encoded', () => { + 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'}}, @@ -306,13 +296,13 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/r/123/flag/a%2Fb/c%26d'); }); - it('5d: query param values with special characters are encoded', () => { + 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('5e: dynamic route with no params at all (params undefined)', () => { + 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'); From 9a329b5b307b61da72f83a820d25c45a5552f4d8 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 31 Mar 2026 12:23:41 +0200 Subject: [PATCH 4/9] remove comment --- tests/navigation/getPathFromStateTests.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/navigation/getPathFromStateTests.ts b/tests/navigation/getPathFromStateTests.ts index 557804111eb5a..95bfb358941e1 100644 --- a/tests/navigation/getPathFromStateTests.ts +++ b/tests/navigation/getPathFromStateTests.ts @@ -222,7 +222,6 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/country/constant-picker?country=US&formType=report&fieldName=status&fieldValue=open'); }); - // no it('two simple dynamic suffixes, no params on first', () => { const state = buildState([{name: 'WalletScreen'}, {name: 'VerifyAccountScreen'}, {name: 'CountryScreen', params: {country: 'US'}}]); From c4def942ab4a9ac503cac23955bdbb75e9483ac7 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 31 Mar 2026 14:41:49 +0200 Subject: [PATCH 5/9] small cleanup --- .../Navigation/helpers/getPathFromState.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index b3ac37364ba38..e0eda34d91568 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -23,6 +23,27 @@ function isDynamicRouteScreen(screenName: Screen): boolean { return dynamicRoutePaths.has(screenPath as DynamicRouteSuffix); } +/** + * 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. @@ -36,17 +57,8 @@ function isDynamicRouteScreen(screenName: Screen): boolean { function buildSuffixFromPattern(pattern: string, params: Record | undefined): string { const pathPart = pattern .split('/') - .map((segment) => { - 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; - }) + .map((segment) => resolveSegment(segment, params)) + // filter(Boolean) is used to remove empty segments .filter(Boolean) .join('/'); @@ -112,7 +124,7 @@ function popFocusedRoute(state: State): State | undefined { * * @private - Internal helper. Do not export or use outside this file. */ -function getPathForDynamicRoute(state: State): string { +function getPathFromStateWithDynamicRoute(state: State): string { const focusedRoute = findFocusedRoute(state); const screenName = focusedRoute?.name ?? ''; const suffixPattern = normalizedConfigs[screenName as Screen]?.path; @@ -147,10 +159,7 @@ function getPathFromState(state: State): string { const screenName = focusedRoute?.name ?? ''; if (isDynamicRouteScreen(screenName as Screen)) { - if (focusedRoute?.path) { - return focusedRoute.path; - } - return getPathForDynamicRoute(state); + return focusedRoute?.path ?? getPathFromStateWithDynamicRoute(state); } return RNGetPathFromState(state, linkingConfig.config); From 587daec9e5faea065c8c5859f0d2be211d2fbec9 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 31 Mar 2026 15:15:06 +0200 Subject: [PATCH 6/9] implement popFocusedRoute without recursion --- .../Navigation/helpers/getPathFromState.ts | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index e0eda34d91568..e06bcfa01937f 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -89,30 +89,54 @@ function buildSuffixFromPattern(pattern: string, params: Record * @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]; + const ancestors: Array<{state: State; index: number}> = []; + let current: State = state; + let index = current.index ?? current.routes.length - 1; + let focusedRoute = current.routes[index]; + + // Descend to the deepest focused route, recording each ancestor level + while (focusedRoute?.state) { + ancestors.push({state: current, index}); + current = focusedRoute.state as State; + index = current.index ?? current.routes.length - 1; + focusedRoute = current.routes[index]; + } + // Remove the leaf route or mark the state as empty + let result: State | undefined; if (!focusedRoute) { - return undefined; + result = undefined; + } else if (current.routes.length > 1) { + const newRoutes = current.routes.filter((_, i) => i !== index); + result = {...current, routes: newRoutes, index: newRoutes.length - 1} as State; + } else { + result = undefined; } - if (focusedRoute.state) { - const nestedResult = popFocusedRoute(focusedRoute.state as 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; + // Rebuild the state tree bottom-up, propagating the removal through ancestors + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors.at(i); + if (!ancestor) { + continue; } - } - if (state.routes.length > 1) { - const newRoutes = state.routes.filter((_, i) => i !== index); - return {...state, routes: newRoutes, index: newRoutes.length - 1} as State; + const {state: ancestorState, index: ancestorIndex} = ancestor; + const route = ancestorState.routes[ancestorIndex]; + + if (result !== undefined) { + const newRoutes = [...ancestorState.routes] as typeof ancestorState.routes; + // @ts-expect-error -- rebuilding a structurally identical route with updated nested state + newRoutes[ancestorIndex] = {...route, state: result}; + result = {...ancestorState, routes: newRoutes, index: ancestorIndex} as State; + } else if (ancestorState.routes.length > 1) { + const newRoutes = ancestorState.routes.filter((_, j) => j !== ancestorIndex); + result = {...ancestorState, routes: newRoutes, index: newRoutes.length - 1} as State; + } else { + result = undefined; + } } - return undefined; + return result; } /** From 167d261551d24d12f2b2b1824d5c61dfcec1dc6e Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 31 Mar 2026 15:18:22 +0200 Subject: [PATCH 7/9] remove cyclic dependency --- src/libs/Navigation/helpers/getPathFromState.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index e06bcfa01937f..d023d0b519026 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -1,7 +1,6 @@ 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'; @@ -154,7 +153,7 @@ function getPathFromStateWithDynamicRoute(state: State): string { const suffixPattern = normalizedConfigs[screenName as Screen]?.path; if (!suffixPattern) { - return RNGetPathFromState(state, linkingConfig.config); + return RNGetPathFromState(state, config); } const actualSuffix = buildSuffixFromPattern(suffixPattern, focusedRoute?.params as Record | undefined); @@ -186,7 +185,7 @@ function getPathFromState(state: State): string { return focusedRoute?.path ?? getPathFromStateWithDynamicRoute(state); } - return RNGetPathFromState(state, linkingConfig.config); + return RNGetPathFromState(state, config); } export default getPathFromState; From 01680a36cceb41aac2faa9e1a098a105ed1c471f Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 31 Mar 2026 15:41:48 +0200 Subject: [PATCH 8/9] fix test --- tests/navigation/getPathFromStateTests.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/navigation/getPathFromStateTests.ts b/tests/navigation/getPathFromStateTests.ts index 95bfb358941e1..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', From 9075968e20f821ea7f4e11824cc796e18362d276 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Tue, 31 Mar 2026 16:06:54 +0200 Subject: [PATCH 9/9] return to recursive approach as it is cleaner --- .../Navigation/helpers/getPathFromState.ts | 61 +++++++------------ 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index d023d0b519026..0c4cdfc33ab82 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -88,54 +88,35 @@ function buildSuffixFromPattern(pattern: string, params: Record * @private - Internal helper. Do not export or use outside this file. */ function popFocusedRoute(state: State): State | undefined { - const ancestors: Array<{state: State; index: number}> = []; - let current: State = state; - let index = current.index ?? current.routes.length - 1; - let focusedRoute = current.routes[index]; - - // Descend to the deepest focused route, recording each ancestor level - while (focusedRoute?.state) { - ancestors.push({state: current, index}); - current = focusedRoute.state as State; - index = current.index ?? current.routes.length - 1; - focusedRoute = current.routes[index]; - } + const index = state.index ?? state.routes.length - 1; + const focusedRoute = state.routes[index]; - // Remove the leaf route or mark the state as empty - let result: State | undefined; + // no focused route exists at this level - nothing to pop. if (!focusedRoute) { - result = undefined; - } else if (current.routes.length > 1) { - const newRoutes = current.routes.filter((_, i) => i !== index); - result = {...current, routes: newRoutes, index: newRoutes.length - 1} as State; - } else { - result = undefined; + return undefined; } - // Rebuild the state tree bottom-up, propagating the removal through ancestors - for (let i = ancestors.length - 1; i >= 0; i--) { - const ancestor = ancestors.at(i); - if (!ancestor) { - continue; - } + // the focused route has nested state — try to pop from deeper levels first. + if (focusedRoute.state) { + const nestedResult = popFocusedRoute(focusedRoute.state as State); - const {state: ancestorState, index: ancestorIndex} = ancestor; - const route = ancestorState.routes[ancestorIndex]; - - if (result !== undefined) { - const newRoutes = [...ancestorState.routes] as typeof ancestorState.routes; - // @ts-expect-error -- rebuilding a structurally identical route with updated nested state - newRoutes[ancestorIndex] = {...route, state: result}; - result = {...ancestorState, routes: newRoutes, index: ancestorIndex} as State; - } else if (ancestorState.routes.length > 1) { - const newRoutes = ancestorState.routes.filter((_, j) => j !== ancestorIndex); - result = {...ancestorState, routes: newRoutes, index: newRoutes.length - 1} as State; - } else { - result = undefined; + // 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; } } - return result; + // 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; } /**