Skip to content
Merged
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
33 changes: 33 additions & 0 deletions src/libs/Navigation/helpers/collectScreensWithTabNavigator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import NAVIGATORS from '@src/NAVIGATORS';

type ScreenConfigEntry = string | {path?: string; screens?: Record<string, ScreenConfigEntry>};

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<string, ScreenConfigEntry>, result: Set<string> = new Set<string>()): Set<string> {
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;
24 changes: 24 additions & 0 deletions src/libs/Navigation/helpers/findFocusedRouteWithOnyxTabGuard.ts
Original file line number Diff line number Diff line change
@@ -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<NavigationState>): ReturnType<typeof findFocusedRoute> {
const route = state.routes[state.index ?? state.routes.length - 1];
if (route === undefined) {
return undefined;
}
if (screensWithOnyxTabNavigator.has(route.name)) {
return route as ReturnType<typeof findFocusedRoute>;
}
if (route.state) {
return findFocusedRouteWithOnyxTabGuard(route.state);
}
return route as ReturnType<typeof findFocusedRoute>;
}

export default findFocusedRouteWithOnyxTabGuard;
37 changes: 4 additions & 33 deletions src/libs/Navigation/helpers/getAdaptedStateFromPath.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native';
import {findFocusedRoute} from '@react-navigation/native';
import pick from 'lodash/pick';
import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState';
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';
Expand All @@ -13,6 +12,7 @@ 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 getRedirectedPath from './getRedirectedPath';
Expand All @@ -28,35 +28,6 @@ type GetAdaptedStateFromPath = (...args: [...Parameters<typeof RNGetStateFromPat
// The function getPathFromState that we are using in some places isn't working correctly without defined index.
const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState<NavigationState> => ({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
* 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<NavigationState>): ReturnType<typeof findFocusedRoute> {
const route = state.routes[state.index ?? state.routes.length - 1];
if (route === undefined) {
return undefined;
}
if ((SCREENS_WITH_ONYX_TAB_NAVIGATOR as readonly string[]).includes(route.name)) {
return route as ReturnType<typeof findFocusedRoute>;
}
if (route.state) {
return findFocusedRouteWithOnyxTabGuard(route.state);
}
return route as ReturnType<typeof findFocusedRoute>;
}

function isRouteWithBackToParam(route: NavigationPartialRoute): route is Route<string, {backTo: string}> {
return route.params !== undefined && 'backTo' in route.params && typeof route.params.backTo === 'string';
}
Expand Down Expand Up @@ -221,14 +192,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);
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/libs/Navigation/helpers/getStateFromPath.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 './findFocusedRouteWithOnyxTabGuard';
import getMatchingNewRoute from './getMatchingNewRoute';
import getRedirectedPath from './getRedirectedPath';

Expand All @@ -32,7 +33,7 @@ function getStateFromPath(path: Route): PartialState<NavigationState> {
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
Expand Down
6 changes: 5 additions & 1 deletion src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -2309,4 +2311,6 @@ const normalizedConfigs = Object.keys(config.screens)
{} as Record<Screen, RouteConfig>,
);

export {normalizedConfigs, config};
const screensWithOnyxTabNavigator = collectScreensWithTabNavigator(config.screens as Record<string, ScreenConfigEntry>);

export {normalizedConfigs, config, screensWithOnyxTabNavigator};
93 changes: 93 additions & 0 deletions tests/navigation/collectScreensWithTabNavigatorTests.ts
Original file line number Diff line number Diff line change
@@ -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<string, ScreenConfigEntry> = {
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<string, ScreenConfigEntry> = {
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<string, ScreenConfigEntry> = {
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<string, ScreenConfigEntry> = {
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<string, ScreenConfigEntry> = {
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<string, ScreenConfigEntry> = {
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']));
});
});
81 changes: 81 additions & 0 deletions tests/navigation/findFocusedRouteWithOnyxTabGuardTests.ts
Original file line number Diff line number Diff line change
@@ -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<string>(),
}));

function buildState(routes: Array<{name: string; state?: PartialState<NavigationState>}>, index?: number): PartialState<NavigationState> {
return {routes, index} as PartialState<NavigationState>;
}

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();
});
});
13 changes: 6 additions & 7 deletions tests/navigation/getMatchingFullScreenRouteTests.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import {findFocusedRoute} from '@react-navigation/native';
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';

jest.mock('@libs/Navigation/linkingConfig/config', () => ({
normalizedConfigs: {},
screensWithOnyxTabNavigator: new Set(),
}));

jest.mock('@libs/ReportUtils', () => ({
getReportOrDraftReport: jest.fn(),
}));

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'};
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
Expand Down
Loading
Loading