From ad357452beb5a0e60b154b6535d1fb0734c69371 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 18 Mar 2026 15:50:45 +0100 Subject: [PATCH 1/5] add tab navigator names collection generated in runtime and add this to dynamic routes handling --- .../helpers/collectScreensWithTabNavigator.ts | 33 +++++++++++++++++++ .../helpers/getAdaptedStateFromPath.ts | 23 ++++--------- .../Navigation/helpers/getStateFromPath.ts | 5 +-- src/libs/Navigation/linkingConfig/config.ts | 6 +++- 4 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 src/libs/Navigation/helpers/collectScreensWithTabNavigator.ts diff --git a/src/libs/Navigation/helpers/collectScreensWithTabNavigator.ts b/src/libs/Navigation/helpers/collectScreensWithTabNavigator.ts new file mode 100644 index 0000000000000..f87374743ef39 --- /dev/null +++ b/src/libs/Navigation/helpers/collectScreensWithTabNavigator.ts @@ -0,0 +1,33 @@ +import NAVIGATORS from '@src/NAVIGATORS'; + +type ScreenConfigEntry = string | {path?: string; screens?: Record}; + +const navigatorNames = new Set(Object.values(NAVIGATORS) as string[]); + +/** + * Recursively walks the linking config tree and collects screen names that host + * an OnyxTabNavigator. These screens are identified by having both a `path` and + * nested `screens` - a pattern unique to tab-hosting screens in the config. + * Navigator entries (from NAVIGATORS) are excluded to avoid false positives. + * + * @param screens - The linking config screens object (or a nested `screens` subtree) + * @param result - Accumulator set passed through recursive calls + * @returns Set of screen names that contain an OnyxTabNavigator + */ +function collectScreensWithTabNavigator(screens: Record, result: Set = new Set()): Set { + const screenConfigEntries = Object.entries(screens); + for (const [screenName, screenConfig] of screenConfigEntries) { + if (typeof screenConfig === 'object' && screenConfig !== null) { + if (screenConfig.path !== undefined && screenConfig.screens && !navigatorNames.has(screenName)) { + result.add(screenName); + } + if (screenConfig.screens) { + collectScreensWithTabNavigator(screenConfig.screens, result); + } + } + } + return result; +} + +export type {ScreenConfigEntry}; +export default collectScreensWithTabNavigator; diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index 930862e77ce54..eaa4351f2f938 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -1,7 +1,7 @@ -import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; -import {findFocusedRoute} from '@react-navigation/native'; +import type {findFocusedRoute, NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; import pick from 'lodash/pick'; import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; +import {screensWithOnyxTabNavigator} from '@libs/Navigation/linkingConfig/config'; import {RHP_TO_DOMAIN, RHP_TO_HOME, RHP_TO_SEARCH, RHP_TO_SETTINGS, RHP_TO_SIDEBAR, RHP_TO_WORKSPACE, RHP_TO_WORKSPACES_LIST} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {NavigationPartialRoute, RootNavigatorParamList} from '@libs/Navigation/types'; import {getReportOrDraftReport} from '@libs/ReportUtils'; @@ -28,15 +28,6 @@ type GetAdaptedStateFromPath = (...args: [...Parameters => ({routes, index: routes.length - 1}); -const SCREENS_WITH_ONYX_TAB_NAVIGATOR = [ - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE, - SCREENS.MONEY_REQUEST.CREATE, - SCREENS.MONEY_REQUEST.DISTANCE_CREATE, - SCREENS.NEW_CHAT.ROOT, - SCREENS.SHARE.ROOT, - SCREENS.WORKSPACE.RECEIPT_PARTNERS_INVITE_EDIT, -] as const; - /** * Works like React Navigation's {@link findFocusedRoute} but stops recursing when it reaches * a screen that hosts an OnyxTabNavigator. Without this guard the lookup would drill into the @@ -48,7 +39,7 @@ function findFocusedRouteWithOnyxTabGuard(state: PartialState): if (route === undefined) { return undefined; } - if ((SCREENS_WITH_ONYX_TAB_NAVIGATOR as readonly string[]).includes(route.name)) { + if (screensWithOnyxTabNavigator.has(route.name)) { return route as ReturnType; } if (route.state) { @@ -221,14 +212,14 @@ function getMatchingFullScreenRoute(route: NavigationPartialRoute) { return lastRoute; } - const focusedStateForDynamicRoute = findFocusedRoute(stateUnderDynamicRoute); + const focusedRouteUnderDynamicRoute = findFocusedRouteWithOnyxTabGuard(stateUnderDynamicRoute); - if (!focusedStateForDynamicRoute) { + if (!focusedRouteUnderDynamicRoute) { return undefined; } // Recursively find the matching full screen route for the focused dynamic route - return getMatchingFullScreenRoute(focusedStateForDynamicRoute); + return getMatchingFullScreenRoute(focusedRouteUnderDynamicRoute); } } @@ -368,4 +359,4 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR }; export default getAdaptedStateFromPath; -export {getMatchingFullScreenRoute, isFullScreenName}; +export {getMatchingFullScreenRoute, isFullScreenName, findFocusedRouteWithOnyxTabGuard}; diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index 2c4d7a11dcce2..283dfc5aa9087 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -1,5 +1,5 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; -import {findFocusedRoute, getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import {getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; import Log from '@libs/Log'; import {linkingConfig} from '@libs/Navigation/linkingConfig'; import type {Route} from '@src/ROUTES'; @@ -9,6 +9,7 @@ import SCREENS from '@src/SCREENS'; import findMatchingDynamicSuffix from './dynamicRoutesUtils/findMatchingDynamicSuffix'; import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; import getStateForDynamicRoute from './dynamicRoutesUtils/getStateForDynamicRoute'; +import {findFocusedRouteWithOnyxTabGuard} from './getAdaptedStateFromPath'; import getMatchingNewRoute from './getMatchingNewRoute'; import getRedirectedPath from './getRedirectedPath'; @@ -32,7 +33,7 @@ function getStateFromPath(path: Route): PartialState { const dynamicRoute: string = dynamicRouteKeys.find((key) => DYNAMIC_ROUTES[key].path === dynamicRouteSuffix) ?? ''; // Get the currently focused route from the base path to check permissions - const focusedRoute = findFocusedRoute(getStateFromPath(pathWithoutDynamicSuffix) ?? {}); + const focusedRoute = findFocusedRouteWithOnyxTabGuard(getStateFromPath(pathWithoutDynamicSuffix) ?? {}); const entryScreens: Screen[] = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? []; // Check if the focused route is allowed to access this dynamic route diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 88f9596621975..b3578f60bdbc4 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1,4 +1,6 @@ import type {LinkingOptions} from '@react-navigation/native'; +import collectScreensWithTabNavigator from '@libs/Navigation/helpers/collectScreensWithTabNavigator'; +import type {ScreenConfigEntry} from '@libs/Navigation/helpers/collectScreensWithTabNavigator'; import type {RouteConfig} from '@libs/Navigation/helpers/createNormalizedConfigs'; import createNormalizedConfigs from '@libs/Navigation/helpers/createNormalizedConfigs'; import type {RootNavigatorParamList} from '@navigation/types'; @@ -2309,4 +2311,6 @@ const normalizedConfigs = Object.keys(config.screens) {} as Record, ); -export {normalizedConfigs, config}; +const screensWithOnyxTabNavigator = collectScreensWithTabNavigator(config.screens as Record); + +export {normalizedConfigs, config, screensWithOnyxTabNavigator}; From ffad3cf193e5198398efef6c4c92ec731ab74627 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 18 Mar 2026 16:03:25 +0100 Subject: [PATCH 2/5] remove cyclic dependencies --- .../findFocusedRouteWithOnyxTabGuard.ts | 24 +++++++++++++++++ .../helpers/getAdaptedStateFromPath.ts | 26 +++---------------- .../Navigation/helpers/getStateFromPath.ts | 2 +- 3 files changed, 28 insertions(+), 24 deletions(-) create mode 100644 src/libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard.ts diff --git a/src/libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard.ts b/src/libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard.ts new file mode 100644 index 0000000000000..345d22d31520c --- /dev/null +++ b/src/libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard.ts @@ -0,0 +1,24 @@ +import type {findFocusedRoute, NavigationState, PartialState} from '@react-navigation/native'; +import {screensWithOnyxTabNavigator} from '@libs/Navigation/linkingConfig/config'; + +/** + * Works like React Navigation's {@link findFocusedRoute} but stops recursing when it reaches + * a screen that hosts an OnyxTabNavigator. Without this guard the lookup would drill into the + * tab navigator's internal state and return the individual tab name (e.g. "amount", "scan") + * instead of the parent screen (e.g. "Money_Request_Split_Expense"). + */ +function findFocusedRouteWithOnyxTabGuard(state: PartialState): ReturnType { + const route = state.routes[state.index ?? state.routes.length - 1]; + if (route === undefined) { + return undefined; + } + if (screensWithOnyxTabNavigator.has(route.name)) { + return route as ReturnType; + } + if (route.state) { + return findFocusedRouteWithOnyxTabGuard(route.state); + } + return route as ReturnType; +} + +export default findFocusedRouteWithOnyxTabGuard; diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index eaa4351f2f938..17528d35b4b81 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -1,7 +1,6 @@ -import type {findFocusedRoute, NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; +import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; import pick from 'lodash/pick'; import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; -import {screensWithOnyxTabNavigator} from '@libs/Navigation/linkingConfig/config'; import {RHP_TO_DOMAIN, RHP_TO_HOME, RHP_TO_SEARCH, RHP_TO_SETTINGS, RHP_TO_SIDEBAR, RHP_TO_WORKSPACE, RHP_TO_WORKSPACES_LIST} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {NavigationPartialRoute, RootNavigatorParamList} from '@libs/Navigation/types'; import {getReportOrDraftReport} from '@libs/ReportUtils'; @@ -15,6 +14,7 @@ import findMatchingDynamicSuffix from './dynamicRoutesUtils/findMatchingDynamicS import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; import getMatchingNewRoute from './getMatchingNewRoute'; import getParamsFromRoute from './getParamsFromRoute'; +import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; import getRedirectedPath from './getRedirectedPath'; import getStateFromPath from './getStateFromPath'; import {isFullScreenName} from './isNavigatorName'; @@ -28,26 +28,6 @@ type GetAdaptedStateFromPath = (...args: [...Parameters => ({routes, index: routes.length - 1}); -/** - * Works like React Navigation's {@link findFocusedRoute} but stops recursing when it reaches - * a screen that hosts an OnyxTabNavigator. Without this guard the lookup would drill into the - * tab navigator's internal state and return the individual tab name (e.g. "amount", "scan") - * instead of the parent screen (e.g. "Money_Request_Split_Expense"). - */ -function findFocusedRouteWithOnyxTabGuard(state: PartialState): ReturnType { - const route = state.routes[state.index ?? state.routes.length - 1]; - if (route === undefined) { - return undefined; - } - if (screensWithOnyxTabNavigator.has(route.name)) { - return route as ReturnType; - } - if (route.state) { - return findFocusedRouteWithOnyxTabGuard(route.state); - } - return route as ReturnType; -} - function isRouteWithBackToParam(route: NavigationPartialRoute): route is Route { return route.params !== undefined && 'backTo' in route.params && typeof route.params.backTo === 'string'; } @@ -359,4 +339,4 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR }; export default getAdaptedStateFromPath; -export {getMatchingFullScreenRoute, isFullScreenName, findFocusedRouteWithOnyxTabGuard}; +export {getMatchingFullScreenRoute, isFullScreenName}; diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index 283dfc5aa9087..5f8d99530ad80 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -9,7 +9,7 @@ import SCREENS from '@src/SCREENS'; import findMatchingDynamicSuffix from './dynamicRoutesUtils/findMatchingDynamicSuffix'; import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; import getStateForDynamicRoute from './dynamicRoutesUtils/getStateForDynamicRoute'; -import {findFocusedRouteWithOnyxTabGuard} from './getAdaptedStateFromPath'; +import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; import getMatchingNewRoute from './getMatchingNewRoute'; import getRedirectedPath from './getRedirectedPath'; From 7eda12f90831b42da915a9b21e4e5dbba9339fa5 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 18 Mar 2026 16:28:47 +0100 Subject: [PATCH 3/5] fix tests --- .../getMatchingFullScreenRouteTests.ts | 13 ++++----- tests/navigation/getStateFromPathTests.ts | 29 ++++++------------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/tests/navigation/getMatchingFullScreenRouteTests.ts b/tests/navigation/getMatchingFullScreenRouteTests.ts index 004b47786b400..5d73479586736 100644 --- a/tests/navigation/getMatchingFullScreenRouteTests.ts +++ b/tests/navigation/getMatchingFullScreenRouteTests.ts @@ -1,10 +1,11 @@ -import {findFocusedRoute} from '@react-navigation/native'; import {getMatchingFullScreenRoute} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import findFocusedRouteWithOnyxTabGuard from '@libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import SCREENS from '@src/SCREENS'; jest.mock('@libs/Navigation/linkingConfig/config', () => ({ normalizedConfigs: {}, + screensWithOnyxTabNavigator: new Set(), })); jest.mock('@libs/ReportUtils', () => ({ @@ -12,9 +13,7 @@ jest.mock('@libs/ReportUtils', () => ({ })); jest.mock('@libs/Navigation/helpers/getStateFromPath', () => jest.fn()); -jest.mock('@react-navigation/native', () => ({ - findFocusedRoute: jest.fn(), -})); +jest.mock('@libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard', () => jest.fn()); jest.mock('@libs/Navigation/linkingConfig/RELATIONS', () => { const SIDEBAR_TO_SPLIT = {SETTINGS_ROOT: 'SettingsSplitNavigator'}; @@ -43,7 +42,7 @@ jest.mock('@src/ROUTES', () => ({ describe('getMatchingFullScreenRoute - dynamic suffix', () => { const mockGetStateFromPath = getStateFromPath as jest.Mock; - const mockFindFocusedRoute = findFocusedRoute as jest.Mock; + const mockFindFocusedRouteWithOnyxTabGuard = findFocusedRouteWithOnyxTabGuard as jest.Mock; beforeEach(() => { jest.clearAllMocks(); @@ -128,12 +127,12 @@ describe('getMatchingFullScreenRoute - dynamic suffix', () => { }; mockGetStateFromPath.mockImplementation((path: string) => (path === '/base' ? basePathState : undefined)); - mockFindFocusedRoute.mockReturnValue(nestedFocusedRoute); + mockFindFocusedRouteWithOnyxTabGuard.mockReturnValue(nestedFocusedRoute); const result = getMatchingFullScreenRoute(route); expect(mockGetStateFromPath).toHaveBeenCalledWith('/base'); - expect(mockFindFocusedRoute).toHaveBeenCalledWith(basePathState); + expect(mockFindFocusedRouteWithOnyxTabGuard).toHaveBeenCalledWith(basePathState); expect(result).toBeDefined(); expect(result?.name).toBe(SCREENS.HOME); }); diff --git a/tests/navigation/getStateFromPathTests.ts b/tests/navigation/getStateFromPathTests.ts index 39548f069a16f..d5329bf114a92 100644 --- a/tests/navigation/getStateFromPathTests.ts +++ b/tests/navigation/getStateFromPathTests.ts @@ -1,11 +1,10 @@ -import {findFocusedRoute, getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import {getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; import Log from '@libs/Log'; import getStateForDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import type {Route} from '@src/ROUTES'; jest.mock('@react-navigation/native', () => ({ - findFocusedRoute: jest.fn(), getStateFromPath: jest.fn(), })); @@ -19,6 +18,10 @@ jest.mock('@libs/Navigation/linkingConfig', () => ({ }, })); +jest.mock('@libs/Navigation/linkingConfig/config', () => ({ + screensWithOnyxTabNavigator: new Set(), +})); + jest.mock('@src/ROUTES', () => ({ DYNAMIC_ROUTES: { SUFFIX_A: { @@ -49,17 +52,16 @@ jest.mock('@libs/Navigation/helpers/getRedirectedPath', () => jest.fn((path: str jest.mock('@libs/Navigation/helpers/dynamicRoutesUtils/getStateForDynamicRoute', () => jest.fn()); describe('getStateFromPath', () => { - const mockFindFocusedRoute = findFocusedRoute as jest.Mock; const mockRNGetStateFromPath = RNGetStateFromPath as jest.Mock; const mockGetStateForDynamicRoute = getStateForDynamicRoute as jest.Mock; const mockLogWarn = jest.spyOn(Log, 'warn'); - const baseRouteState = {routes: [{name: 'BaseScreen'}]}; - const dynamicSuffixAState = {routes: [{name: 'DynamicSuffixAScreen'}]}; + const focusedRouteParams = {baseParam: '123'}; + const baseRouteState = {routes: [{name: 'BaseScreen', params: focusedRouteParams}]}; + const dynamicSuffixAState = {routes: [{name: 'DynamicSuffixAScreen', params: focusedRouteParams}]}; const dynamicSuffixBState = {routes: [{name: 'DynamicSuffixBScreen'}]}; - const dynamicMultiSegState = {routes: [{name: 'DynamicMultiSegScreen'}]}; + const dynamicMultiSegState = {routes: [{name: 'DynamicMultiSegScreen', params: focusedRouteParams}]}; const dynamicMultiSegLayerState = {routes: [{name: 'DynamicMultiSegLayerScreen'}]}; - const focusedRouteParams = {baseParam: '123'}; beforeEach(() => { jest.clearAllMocks(); @@ -79,18 +81,6 @@ describe('getStateFromPath', () => { } return {routes: [{name: 'UnknownDynamic'}]}; }); - mockFindFocusedRoute.mockImplementation((state: unknown) => { - if (state === baseRouteState) { - return {name: 'BaseScreen', params: focusedRouteParams}; - } - if (state === dynamicSuffixAState) { - return {name: 'DynamicSuffixAScreen', params: focusedRouteParams}; - } - if (state === dynamicMultiSegState) { - return {name: 'DynamicMultiSegScreen', params: focusedRouteParams}; - } - return undefined; - }); }); it('should delegate to RN getStateFromPath for standard routes (non-dynamic)', () => { @@ -116,7 +106,6 @@ describe('getStateFromPath', () => { const fullPath = '/unknown/suffix-b-unauth'; const standardState = {routes: [{name: 'FallbackRoute'}]}; mockRNGetStateFromPath.mockReturnValue(standardState); - mockFindFocusedRoute.mockReturnValue({name: 'UnknownScreen'}); const result = getStateFromPath(fullPath as unknown as Route); From af3efe7ef6be503706719c55e22d89fd476017db Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Wed, 18 Mar 2026 16:37:13 +0100 Subject: [PATCH 4/5] fix prettier --- src/libs/Navigation/helpers/getAdaptedStateFromPath.ts | 2 +- tests/navigation/getMatchingFullScreenRouteTests.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index 17528d35b4b81..dad7030686046 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -12,9 +12,9 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import findMatchingDynamicSuffix from './dynamicRoutesUtils/findMatchingDynamicSuffix'; import getPathWithoutDynamicSuffix from './dynamicRoutesUtils/getPathWithoutDynamicSuffix'; +import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; import getMatchingNewRoute from './getMatchingNewRoute'; import getParamsFromRoute from './getParamsFromRoute'; -import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; import getRedirectedPath from './getRedirectedPath'; import getStateFromPath from './getStateFromPath'; import {isFullScreenName} from './isNavigatorName'; diff --git a/tests/navigation/getMatchingFullScreenRouteTests.ts b/tests/navigation/getMatchingFullScreenRouteTests.ts index 5d73479586736..ceb1c41c84ef5 100644 --- a/tests/navigation/getMatchingFullScreenRouteTests.ts +++ b/tests/navigation/getMatchingFullScreenRouteTests.ts @@ -1,5 +1,5 @@ -import {getMatchingFullScreenRoute} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; import findFocusedRouteWithOnyxTabGuard from '@libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard'; +import {getMatchingFullScreenRoute} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import SCREENS from '@src/SCREENS'; From 313c1719a69dc5ff53ca020a09ed8fae31188293 Mon Sep 17 00:00:00 2001 From: Yehor Kharchenko Date: Fri, 20 Mar 2026 10:47:26 +0100 Subject: [PATCH 5/5] add tests --- .../collectScreensWithTabNavigatorTests.ts | 93 +++++++++++++++++++ .../findFocusedRouteWithOnyxTabGuardTests.ts | 81 ++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tests/navigation/collectScreensWithTabNavigatorTests.ts create mode 100644 tests/navigation/findFocusedRouteWithOnyxTabGuardTests.ts diff --git a/tests/navigation/collectScreensWithTabNavigatorTests.ts b/tests/navigation/collectScreensWithTabNavigatorTests.ts new file mode 100644 index 0000000000000..8349f28704f4f --- /dev/null +++ b/tests/navigation/collectScreensWithTabNavigatorTests.ts @@ -0,0 +1,93 @@ +import type {ScreenConfigEntry} from '@libs/Navigation/helpers/collectScreensWithTabNavigator'; +import collectScreensWithTabNavigator from '@libs/Navigation/helpers/collectScreensWithTabNavigator'; + +jest.mock('@src/NAVIGATORS', () => { + const navigators = { + CENTRAL_PANE_NAVIGATOR: 'CentralPaneNavigator', + RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator', + }; + return navigators; +}); + +describe('collectScreensWithTabNavigator', () => { + it('should skip string configs and object configs missing path or screens', () => { + const screens: Record = { + StringScreen: 'home', + PathOnly: {path: '/settings'}, + ScreensOnly: {screens: {Child: 'child'}}, + }; + + const result = collectScreensWithTabNavigator(screens); + + expect(result.size).toBe(0); + }); + + it('should collect a screen that has both path and screens', () => { + const screens: Record = { + TabScreen: {path: '/tabs', screens: {Tab1: 'tab1', Tab2: 'tab2'}}, + }; + + const result = collectScreensWithTabNavigator(screens); + + expect(result).toEqual(new Set(['TabScreen'])); + }); + + it('should exclude navigator names even if they have both path and screens', () => { + const screens: Record = { + CentralPaneNavigator: {path: '/central', screens: {Inner: 'inner'}}, + RightModalNavigator: {path: '/modal', screens: {Inner: 'inner'}}, + }; + + const result = collectScreensWithTabNavigator(screens); + + expect(result.size).toBe(0); + }); + + it('should collect a nested tab screen inside a navigator', () => { + const screens: Record = { + CentralPaneNavigator: { + screens: { + NestedTabScreen: {path: '/nested', screens: {Tab1: 'tab1'}}, + }, + }, + }; + + const result = collectScreensWithTabNavigator(screens); + + expect(result).toEqual(new Set(['NestedTabScreen'])); + }); + + it('should collect tab screens at all depth levels in a 3-level structure', () => { + const screens: Record = { + Level1Tab: { + path: '/l1', + screens: { + Wrapper: { + screens: { + Level3Tab: {path: '/l3', screens: {DeepTab: 'deep'}}, + }, + }, + }, + }, + }; + + const result = collectScreensWithTabNavigator(screens); + + expect(result).toEqual(new Set(['Level1Tab', 'Level3Tab'])); + }); + + it('should collect only matching screens from a mixed config', () => { + const screens: Record = { + SimpleString: 'home', + PathOnly: {path: '/settings'}, + ScreensOnly: {screens: {A: 'a'}}, + CentralPaneNavigator: {path: '/nav', screens: {X: 'x'}}, + ValidTab: {path: '/valid', screens: {T1: 't1'}}, + AnotherTab: {path: '/another', screens: {T2: 't2'}}, + }; + + const result = collectScreensWithTabNavigator(screens); + + expect(result).toEqual(new Set(['ValidTab', 'AnotherTab'])); + }); +}); diff --git a/tests/navigation/findFocusedRouteWithOnyxTabGuardTests.ts b/tests/navigation/findFocusedRouteWithOnyxTabGuardTests.ts new file mode 100644 index 0000000000000..3cc7f04512848 --- /dev/null +++ b/tests/navigation/findFocusedRouteWithOnyxTabGuardTests.ts @@ -0,0 +1,81 @@ +import type {NavigationState, PartialState} from '@react-navigation/native'; +import findFocusedRouteWithOnyxTabGuard from '@libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard'; +import {screensWithOnyxTabNavigator as guardSet} from '@libs/Navigation/linkingConfig/config'; + +jest.mock('@libs/Navigation/linkingConfig/config', () => ({ + screensWithOnyxTabNavigator: new Set(), +})); + +function buildState(routes: Array<{name: string; state?: PartialState}>, index?: number): PartialState { + return {routes, index} as PartialState; +} + +describe('findFocusedRouteWithOnyxTabGuard', () => { + beforeEach(() => { + guardSet.clear(); + }); + + it('should return the only route when there is a single route with no nested state', () => { + const state = buildState([{name: 'Home'}]); + + expect(findFocusedRouteWithOnyxTabGuard(state)).toMatchObject({name: 'Home'}); + }); + + it('should return the route at the explicit index, or the last route when index is omitted', () => { + const stateWithIndex = buildState([{name: 'First'}, {name: 'Second'}], 1); + expect(findFocusedRouteWithOnyxTabGuard(stateWithIndex)).toMatchObject({name: 'Second'}); + + const stateWithoutIndex = buildState([{name: 'First'}, {name: 'Second'}, {name: 'Third'}]); + expect(findFocusedRouteWithOnyxTabGuard(stateWithoutIndex)).toMatchObject({name: 'Third'}); + }); + + it('should stop recursing and return the route when its name is in the guard set', () => { + guardSet.add('SplitExpense'); + + const state = buildState([ + { + name: 'SplitExpense', + state: buildState([{name: 'AmountTab'}]), + }, + ]); + + const result = findFocusedRouteWithOnyxTabGuard(state); + + expect(result).toMatchObject({name: 'SplitExpense'}); + }); + + it('should recurse into nested state when the route name is not in the guard set', () => { + const state = buildState([ + { + name: 'Navigator', + state: buildState([{name: 'LeafScreen'}]), + }, + ]); + + expect(findFocusedRouteWithOnyxTabGuard(state)).toMatchObject({name: 'LeafScreen'}); + }); + + it('should stop at the middle level when the guard triggers there, not at the leaf', () => { + guardSet.add('MiddleScreen'); + + const state = buildState([ + { + name: 'Root', + state: buildState([ + { + name: 'MiddleScreen', + state: buildState([{name: 'DeepLeaf'}]), + }, + ]), + }, + ]); + + expect(findFocusedRouteWithOnyxTabGuard(state)).toMatchObject({name: 'MiddleScreen'}); + }); + + it('should return undefined when routes array is empty', () => { + const state = buildState([]); + + expect(findFocusedRouteWithOnyxTabGuard(state)).toBeUndefined(); + }); +});