Skip to content
Merged
24 changes: 12 additions & 12 deletions src/libs/Navigation/helpers/getAdaptedStateFromPath.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState';
import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS';
import type {NavigationPartialRoute, NavigationRoute, RootNavigatorParamList} from '@libs/Navigation/types';
import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config';
import type {NavigationRoute} from '@libs/Navigation/types';
import {hasKey} from '@libs/ObjectUtils';
import {getReportOrDraftReport} from '@libs/ReportUtils';

import NAVIGATORS from '@src/NAVIGATORS';
import type {Route as RoutePath} from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import type {Screen} from '@src/SCREENS';

import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native';

Expand Down Expand Up @@ -82,20 +82,20 @@ function getOnboardingAdaptedState(state: PartialState<NavigationState>): Partia
return getRoutesWithIndex(routes);
}

function getAdaptedState(state: PartialState<NavigationState<RootNavigatorParamList>>): GetAdaptedStateReturnType {
function getAdaptedState(state: PartialState<NavigationState>): GetAdaptedStateReturnType {
let currentState = state;
const fullScreenRoute = currentState.routes.find((route) => isFullScreenName(route.name));

if (fullScreenRoute?.name === NAVIGATORS.TAB_NAVIGATOR) {
let tabState = fullScreenRoute.state as PartialState<NavigationState> | undefined;
let tabState = fullScreenRoute.state;

// RN's getStateFromPath emits only the tab matched by the path, so the TAB_NAVIGATOR strip may be sparse.
// Rebuild the full strip around the active tab — consumers (e.g. REPLACE_FULLSCREEN_UNDER_RHP) expect every tab to be present.
// Only the active tab's nested state is carried over; any other tabs in a sparse strip are placeholders without state.
if (tabState?.routes && tabState.routes.length < TAB_SCREENS.length) {
const activeTabRoute = tabState.routes.at(tabState.index ?? tabState.routes.length - 1);
if (activeTabRoute) {
tabState = getTabNavigatorState(activeTabRoute as NavigationPartialRoute).state;
tabState = getTabNavigatorState(activeTabRoute).state;
const normalizedRoutes = currentState.routes.map((r) => (r === fullScreenRoute ? {...r, state: tabState} : r));
currentState = {...currentState, routes: normalizedRoutes};
}
Expand All @@ -104,19 +104,19 @@ function getAdaptedState(state: PartialState<NavigationState<RootNavigatorParamL
// If TAB_NAVIGATOR contains WORKSPACE_NAVIGATOR, ensure WORKSPACES_LIST is in its nested state
const wsNavRoute = tabState?.routes?.find((r) => r.name === NAVIGATORS.WORKSPACE_NAVIGATOR);
if (wsNavRoute) {
const wsNavState = wsNavRoute.state as PartialState<NavigationState> | undefined;
const wsNavState = wsNavRoute.state;
const hasWorkspacesList = wsNavState?.routes?.some((r) => r.name === SCREENS.WORKSPACES_LIST);

if (!hasWorkspacesList && wsNavState?.routes?.length) {
const updatedNestedState = getRoutesWithIndex([{name: SCREENS.WORKSPACES_LIST}, ...(wsNavState.routes ?? [])]);
const updatedWsNavRoute = {...wsNavRoute, state: updatedNestedState};
const updatedTabRoutes = (tabState?.routes ?? []).map((r) => (r.name === NAVIGATORS.WORKSPACE_NAVIGATOR ? updatedWsNavRoute : r)) as NavigationPartialRoute[];
const updatedTabRoutes = (tabState?.routes ?? []).map((r) => (r.name === NAVIGATORS.WORKSPACE_NAVIGATOR ? updatedWsNavRoute : r));
const updatedTabState = {...tabState, routes: updatedTabRoutes};
const updatedFullScreenRoute = {
...fullScreenRoute,
state: updatedTabState,
};
const updatedRoutes = currentState.routes.map((r) => (r.name === NAVIGATORS.TAB_NAVIGATOR ? updatedFullScreenRoute : r)) as NavigationPartialRoute[];
const updatedRoutes = currentState.routes.map((r) => (r.name === NAVIGATORS.TAB_NAVIGATOR ? updatedFullScreenRoute : r));
return getRoutesWithIndex(updatedRoutes);
}
}
Expand All @@ -126,8 +126,8 @@ function getAdaptedState(state: PartialState<NavigationState<RootNavigatorParamL
if (!fullScreenRoute) {
const focusedRoute = findFocusedRouteWithOnyxTabGuard(currentState);

if (focusedRoute?.path && isDynamicRouteScreen(focusedRoute.name as Screen)) {
currentState = getDynamicRouteAdaptedState(currentState, focusedRoute.path) as PartialState<NavigationState<RootNavigatorParamList>>;
if (focusedRoute?.path && hasKey(normalizedConfigs, focusedRoute.name) && isDynamicRouteScreen(focusedRoute.name)) {
currentState = getDynamicRouteAdaptedState(currentState, focusedRoute.path);

// getDynamicRouteAdaptedState may have already resolved the full screen route.
// In that case, skip the default full screen route injection below - the state is already complete.
Expand Down Expand Up @@ -200,7 +200,7 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR
let normalizedPath = normalizePath(path);
normalizedPath = getMatchingNewRoute(normalizedPath) ?? normalizedPath;

const state = getStateFromPath(normalizedPath as RoutePath) as PartialState<NavigationState<RootNavigatorParamList>>;
const state = getStateFromPath(normalizedPath);
if (shouldReplacePathInNestedState) {
replacePathInNestedState(state, normalizedPath);
}
Expand Down
10 changes: 4 additions & 6 deletions src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Do not import ReportUtils or anything that imports Navigation here, or Navigation rejoins the import/no-cycle graph.
*/
import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState';
import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config';
import {
RHP_TO_DOMAIN,
RHP_TO_HOME,
Expand All @@ -15,13 +16,12 @@ import {
RHP_TO_WORKSPACES_LIST,
} from '@libs/Navigation/linkingConfig/RELATIONS';
import type {NavigationPartialRoute, NavigationRoute} from '@libs/Navigation/types';
import {hasKey} from '@libs/ObjectUtils';
import {getSearchParamFromPath} from '@libs/Url';

import NAVIGATORS from '@src/NAVIGATORS';
import type {Route as RoutePath} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import type {Screen} from '@src/SCREENS';

import type {NavigationState, PartialState, Route} from '@react-navigation/native';

Expand Down Expand Up @@ -65,16 +65,14 @@ function isRouteWithBackToParam(route: NavigationRoute): route is Route<string,
* without changing the page currently underneath.
*/
function getMatchingFullScreenRoute(route: NavigationRoute, isDeeplink = false) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- route names in navigation state are typed as string
const isDynamicScreen = isDynamicRouteScreen(route.name as Screen);
const isDynamicScreen = hasKey(normalizedConfigs, route.name) && isDynamicRouteScreen(route.name);

// Check for backTo param. One screen with different backTo value may need different screens visible under the overlay.
// Dynamic screens are skipped here because they never carry their own backTo - they only
// inherit it from the screen underneath. Letting backTo dictate the full-screen route for
// a dynamic screen would resolve the wrong page.
if (isRouteWithBackToParam(route) && !isDynamicScreen) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- backTo is a path string read from route params
const stateForBackTo = getStateFromPath(route.params.backTo as RoutePath);
const stateForBackTo = getStateFromPath(route.params.backTo);

// This may happen if the backTo url is invalid.
const lastRoute = stateForBackTo?.routes.at(-1);
Expand Down
49 changes: 31 additions & 18 deletions src/libs/Navigation/helpers/getPathFromState.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Log from '@libs/Log';
import {config, normalizedConfigs, screensWithOnyxTabNavigator} from '@libs/Navigation/linkingConfig/config';
import type {State} from '@libs/Navigation/types';
import {hasKey, isRecord} from '@libs/ObjectUtils';

import type {Screen} from '@src/SCREENS';
import type {NavigationState, PartialState} from '@react-navigation/native';

import {getPathFromState as RNGetPathFromState} from '@react-navigation/native';

Expand All @@ -13,10 +14,6 @@ import splitPathAndQuery from './dynamicRoutesUtils/splitPathAndQuery';
import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard';
import {collapseRepeatedSlashes} from './normalizePath';

function isScreen(name: string): name is Screen {
return name in normalizedConfigs;
}

/**
* 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.
Expand Down Expand Up @@ -82,6 +79,9 @@ function buildSuffixFromPattern(pattern: string, params: Record<string, unknown>
*
* @private - Internal helper. Do not export or use outside this file.
*/
function popFocusedRoute(state: NavigationState): NavigationState | undefined;
function popFocusedRoute(state: PartialState<NavigationState>): PartialState<NavigationState> | undefined;
function popFocusedRoute(state: State): State | undefined;
function popFocusedRoute(state: State): State | undefined {
const index = state.index ?? state.routes.length - 1;
const focusedRoute = state.routes[index];
Expand All @@ -94,21 +94,34 @@ function popFocusedRoute(state: State): State | undefined {
// the focused route has nested state - try to pop from deeper levels first,
// unless it hosts an OnyxTabNavigator (treat it as a leaf).
if (focusedRoute.state && focusedRoute.name && !screensWithOnyxTabNavigator.has(focusedRoute.name)) {
const nestedResult = popFocusedRoute(focusedRoute.state as State);

// A deeper route was successfully popped - rebuild the current level with the updated nested state.
if (nestedResult) {
const newRoutes = [...state.routes] as typeof state.routes;
// @ts-expect-error -- we're rebuilding a structurally identical route with updated nested state
newRoutes[index] = {...focusedRoute, state: nestedResult};
return {...state, routes: newRoutes, index} as State;
// Rebuild from the narrowed state to preserve full and partial route categories.
if (state.stale === false) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 No tests were added, and Codecov shows this file at 63.63% patch coverage (-9.05%).

The existing getPathFromStateTests.ts states have no stale, so only the partial branch runs. This stale === false branch is the one the app hits with a full root state.

Can we add stale: false cases for:

  • a nested dynamic route
  • removing a sibling route

👁️ Written by AI, reviewed by Kevin.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two complete stale: false state cases: nested full-state rebuilding and removal of a focused dynamic sibling from the middle of a full root. Both assert the resulting path, the exact reduced state passed to the base serializer, and input immutability. Coverage confirms both full-state branches execute, and mutation probes catch dropping the nested rebuild or removing the wrong sibling.

const nestedResult = popFocusedRoute(focusedRoute.state);
const fullFocusedRoute = state.routes.at(index);
if (nestedResult && fullFocusedRoute) {
const newRoutes = [...state.routes];
newRoutes[index] = {...fullFocusedRoute, state: nestedResult};
return {...state, routes: newRoutes, index};
}
} else {
const partialFocusedRoute = state.routes.at(index);
const nestedResult = partialFocusedRoute?.state ? popFocusedRoute(partialFocusedRoute.state) : undefined;
if (nestedResult && partialFocusedRoute) {
const newRoutes = [...state.routes];
newRoutes[index] = {...partialFocusedRoute, state: nestedResult};
return {...state, routes: newRoutes, index};
}
}
}

// remove the focused route itself if siblings remain.
if (state.routes.length > 1) {
if (state.stale === false) {
const newRoutes = state.routes.filter((_, i) => i !== index);
return {...state, routes: newRoutes, index: newRoutes.length - 1};
}
const newRoutes = state.routes.filter((_, i) => i !== index);
return {...state, routes: newRoutes, index: newRoutes.length - 1} as State;
return {...state, routes: newRoutes, index: newRoutes.length - 1};
}

// Only one route at this level and nothing deeper to pop — signal the parent to remove this level entirely.
Expand All @@ -127,21 +140,21 @@ function popFocusedRoute(state: State): State | undefined {
function getPathFromStateWithDynamicRoute(state: State): string {
const focusedRoute = findFocusedRouteWithOnyxTabGuard(state);
const screenName = focusedRoute?.name ?? '';
const suffixPattern = normalizedConfigs[screenName as Screen]?.path;
const suffixPattern = hasKey(normalizedConfigs, screenName) ? normalizedConfigs[screenName]?.path : undefined;

if (!suffixPattern) {
return RNGetPathFromState(state, config);
}

let actualSuffix = buildSuffixFromPattern(suffixPattern, focusedRoute?.params as Record<string, unknown> | undefined);
let actualSuffix = buildSuffixFromPattern(suffixPattern, isRecord(focusedRoute?.params) ? focusedRoute.params : undefined);

// If this dynamic screen hosts a tab navigator, append the focused tab's path segment.
if (screensWithOnyxTabNavigator.has(screenName)) {
const tabState = focusedRoute?.state;
if (tabState) {
const tabIndex = tabState.index ?? tabState.routes.length - 1;
const focusedTab = tabState.routes[tabIndex];
const tabPath = focusedTab && isScreen(focusedTab.name) ? normalizedConfigs[focusedTab.name]?.path : undefined;
const tabPath = focusedTab && hasKey(normalizedConfigs, focusedTab.name) ? normalizedConfigs[focusedTab.name]?.path : undefined;
if (tabPath) {
const [suffixPathOnly, suffixQueryOnly] = splitPathAndQuery(actualSuffix);
actualSuffix = `${suffixPathOnly}/${tabPath}${suffixQueryOnly ? `?${suffixQueryOnly}` : ''}`;
Expand Down Expand Up @@ -181,7 +194,7 @@ function getPathFromState(state: State): string {
const focusedRoute = findFocusedRouteWithOnyxTabGuard(state);
const screenName = focusedRoute?.name ?? '';

return isDynamicRouteScreen(screenName as Screen) ? getPathFromStateWithDynamicRoute(state) : RNGetPathFromState(state, config);
return hasKey(normalizedConfigs, screenName) && isDynamicRouteScreen(screenName) ? getPathFromStateWithDynamicRoute(state) : RNGetPathFromState(state, config);
}

export default getPathFromState;
3 changes: 1 addition & 2 deletions src/libs/Navigation/helpers/getStateFromPath.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Log from '@libs/Log';
import {config} from '@libs/Navigation/linkingConfig/config';

import type {Route} from '@src/ROUTES';
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import type {Screen} from '@src/SCREENS';
import SCREENS from '@src/SCREENS';
Expand All @@ -21,7 +20,7 @@ import normalizePath from './normalizePath';
* @param path - The path to parse
* @returns - It's possible that there is no navigation action for the given path
*/
function getStateFromPath(path: Route): PartialState<NavigationState> {
function getStateFromPath(path: string): PartialState<NavigationState> {
const normalizedPath = normalizePath(path);
const normalizedPathAfterRedirection = getMatchingNewRoute(normalizedPath) ?? normalizedPath;

Expand Down
2 changes: 1 addition & 1 deletion src/libs/Navigation/helpers/hasDifferentSplitScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function getSplitScopeComparisonValues(route: NavigationRoute, payload: ActionPa
const scopeRoute = sidebarRoute ?? splitState?.routes.at(splitState.index ?? -1);
// `getActionFromState` nests a split's screen params under `params.params`, so both sides read one level down.
const currentParams: unknown = scopeRoute?.params ?? (isRecord(route.params) ? route.params.params : undefined);
const targetParams = payload.params?.params;
const targetParams = isRecord(payload.params) ? payload.params.params : undefined;
if (!isRecord(currentParams) || !isRecord(targetParams)) {
return;
}
Expand Down
8 changes: 4 additions & 4 deletions src/libs/Navigation/helpers/linkTo/getMinimalAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ function isNamedActionPayload(payload: unknown): payload is ActionPayload & {nam
* addresses nothing below that route, which is where a descent stops.
*/
function getNestedAction(action: NavigationAction, nestedState: State): Writable<NavigationAction> {
const params = isNamedActionPayload(action.payload) ? action.payload.params : undefined;
const params = isNamedActionPayload(action.payload) && isRecord(action.payload.params) ? action.payload.params : undefined;
return {
type: action.type,
payload: {
name: params?.screen,
params: params?.params,
path: params?.path,
name: typeof params?.screen === 'string' ? params.screen : undefined,
params: isRecord(params?.params) ? params.params : undefined,
path: typeof params?.path === 'string' ? params.path : undefined,
},
target: nestedState.key,
};
Expand Down
Loading
Loading