Skip to content
25 changes: 21 additions & 4 deletions src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {navigationRef} from '@libs/Navigation/Navigation';
import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState';
import navigationRef from '@libs/Navigation/navigationRef';
import type {NavigationRoute, RootNavigatorParamList, State} from '@libs/Navigation/types';
import NAVIGATORS from '@src/NAVIGATORS';

/**
* Returns the active tab route of the topmost TAB_NAVIGATOR in the root navigation state.
* Use this to determine which full-screen tab (Search, Inbox, etc.) is currently focused.
*
* Fallback chain: live tab state → preserved state.
*/
function getTopmostFullScreenRoute(): NavigationRoute | undefined {
const rootState = navigationRef.getRootState() as State<RootNavigatorParamList>;
Expand All @@ -14,11 +17,25 @@ function getTopmostFullScreenRoute(): NavigationRoute | undefined {
}

const topmostTabNavigatorRoute = rootState.routes.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR);
if (!topmostTabNavigatorRoute?.state) {
if (!topmostTabNavigatorRoute) {
return undefined;
}
const index = topmostTabNavigatorRoute.state.index ?? 0;
return topmostTabNavigatorRoute.state.routes?.at(index);

const liveState = topmostTabNavigatorRoute.state;
const liveRoute = liveState ? liveState.routes?.at(liveState.index ?? 0) : undefined;
if (liveRoute) {
return liveRoute;
}

const preservedState = topmostTabNavigatorRoute.key ? getPreservedNavigatorState(topmostTabNavigatorRoute.key) : undefined;
if (preservedState) {
const preservedRoute = preservedState.routes?.at(preservedState.index ?? 0);
if (preservedRoute) {
return preservedRoute;
Comment thread
JakubKorytko marked this conversation as resolved.
}
}

return undefined;
}

export default getTopmostFullScreenRoute;
19 changes: 12 additions & 7 deletions src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ type NavigateAfterExpenseCreateParams = {
shouldNavigate?: boolean;
};

function getNavigateAfterCreateSearchNavigatorState() {
const rootState = navigationRef.getRootState();
const searchNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR);
return searchNavigatorRoute?.state;
}

Comment on lines +25 to +30

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.

⚠️ Code Quality

Strength: Extraction of getNavigateAfterCreateSearchNavigatorState into a named function improves readability and testability.

Issue: This new helper does not use the improved getCurrentSearchQueryJSON / getTopmostFullScreenRoute logic. It directly accesses navigationRef.getRootState().

This function skips the new fallback chain entirely. If the SEARCH_FULLSCREEN_NAVIGATOR is inside a TAB_NAVIGATOR but its .state is missing (the scenario this PR fixes), this function returns undefined, and alreadyOnSearchRoot becomes false. This means the telemetry will record NAVIGATE_TO_SEARCH instead of DISMISS_MODAL_ONLY, which may affect performance metrics.

Bug potential: If the SEARCH_FULLSCREEN_NAVIGATOR is not at the root level but nested inside TAB_NAVIGATOR, rootState.routes.findLast(...) will not find it. This is the pre-existing behavior, but the PR doesn't address it. The helper should probably use the same state-finding logic as getCurrentSearchQueryJSON.

/**
* Helper to navigate after an expense is created in order to standardize the post‑creation experience
* when creating an expense from the global create button.
Expand Down Expand Up @@ -61,15 +67,14 @@ function navigateAfterExpenseCreate({

// When already on Search ROOT with the same type (expense vs invoice), we navigate to the same screen (no-op or refresh); record as dismiss_modal_only.
// When on another Search sub-tab (e.g. Chats), or on Search with a different type (e.g. on Invoice, submitting expense), record as navigate_to_search.
const rootState = navigationRef.getRootState();
const searchNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR);
const lastSearchRoute = searchNavigatorRoute?.state?.routes?.at(-1);
const alreadyOnSearchRoot = isSearchTopmostFullScreenRoute() && lastSearchRoute?.name === SCREENS.SEARCH.ROOT;
const searchNavigatorState = getNavigateAfterCreateSearchNavigatorState();
const lastSearchRoute = searchNavigatorState?.routes?.at(-1);
const isSearchTopmost = isSearchTopmostFullScreenRoute();
const alreadyOnSearchRoot = isSearchTopmost && lastSearchRoute?.name === SCREENS.SEARCH.ROOT;
const currentSearchQueryJSON = alreadyOnSearchRoot ? getCurrentSearchQueryJSON() : undefined;
const isSameSearchType = currentSearchQueryJSON?.type === type;
setPendingSubmitFollowUpAction(
alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH,
);
const followUpAction = alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH;
setPendingSubmitFollowUpAction(followUpAction);

const queryString = buildCannedSearchQuery({type});
const navigateToSearch = () => {
Expand Down
122 changes: 110 additions & 12 deletions src/libs/SearchQueryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ import {hashText} from './UserUtils';
import {isValidDate} from './ValidationUtils';

type FilterKeys = keyof typeof CONST.SEARCH.SYNTAX_FILTER_KEYS;
type SearchRootParams = SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH.ROOT];
type NavigationRouteLike = {
key?: unknown;
name?: unknown;
params?: unknown;
state?: unknown;
};
Comment on lines +60 to +65

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.

🟡 Issue: NavigationRouteLike type uses unknown where string would be more precise

Impact: Type safety is weakened. getRouteKey has to do typeof route.key === 'string' because the type allows unknown which defeats the purpose of having a typed structure.

Recommendation:

type NavigationRouteLike = {
    key?: string;
    name?: string;
    params?: Record<string, unknown>;
    state?: unknown;
};

Then getRouteKey simplifies to:

function getRouteKey(route: NavigationRouteLike | unknown): string | undefined {
    return isRecord(route) && typeof route.key === 'string' ? route.key : undefined;
}

(Kept the runtime check since route is unknown at call site.)


// This map contains chars that match each operator
const operatorToCharMap = {
Expand Down Expand Up @@ -1916,29 +1923,120 @@ function getQueryWithUpdatedValues(query: string, shouldSkipAmountConversion = f
return buildSearchQueryString(standardizedQuery);
}

function isSearchRootParams(params: unknown): params is SearchRootParams {
return (
!!params &&
typeof params === 'object' &&
'q' in params &&
typeof params.q === 'string' &&
(!('rawQuery' in params) || params.rawQuery === undefined || typeof params.rawQuery === 'string')
);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
Comment on lines +1936 to +1938

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.

🟡 Issue: isRecord duplicates existing utility in ImportOnyxStateUtils.ts

// SearchQueryUtils.ts (line 1936)
function isRecord(value: unknown): value is Record<string, unknown> {
    return !!value && typeof value === 'object' && !Array.isArray(value);
}

// ImportOnyxStateUtils.ts (line 13)
function isRecord(value: unknown): value is Record<string, unknown> {
    return typeof value === 'object' && !Array.isArray(value) && value !== null;
}

Impact: Code duplication. Two identical type guards in the same code base.

Recommendation: Extract isRecord to a shared utility (e.g. @libs/isRecord.ts or @libs/ObjectUtils.ts). The ImportOnyxStateUtils version with explicit value !== null && is slightly more readable.


function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
Comment on lines +1940 to +1942

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.

🟡 Issue: isUnknownArray is just a type-guard wrapper around Array.isArray

This adds function call overhead for minimal value. TypeScript's Array.isArray already narrows to unknown[] when called on unknown. Consider inlining it or keeping it only if strict lint rules require the guard.


function getRouteKey(route: unknown): string | undefined {
return isRecord(route) && typeof route.key === 'string' ? route.key : undefined;
}

function getRouteParams(route: unknown): unknown {
return isRecord(route) ? route.params : undefined;
}

function getRouteState(route: unknown): unknown {
return isRecord(route) ? route.state : undefined;
}

function getParamsState(params: unknown): unknown {
return isRecord(params) ? params.state : undefined;
}

function getRoutes(state: unknown): unknown[] | undefined {
if (!isRecord(state) || !isUnknownArray(state.routes)) {
return undefined;
}
return state.routes;
}

function getLastRouteByName(state: unknown, routeName: string): NavigationRouteLike | undefined {
const routes = getRoutes(state);
const route = routes?.findLast((candidate) => isRecord(candidate) && candidate.name === routeName);
return isRecord(route) ? route : undefined;
}

function getSearchRootParamsFromNestedNavigatorParams(params: unknown): SearchRootParams | undefined {
if (!params || typeof params !== 'object') {
return undefined;
}

const screen = 'screen' in params ? params.screen : undefined;
const nestedParams = 'params' in params ? params.params : undefined;
if (screen === SCREENS.SEARCH.ROOT) {
return isSearchRootParams(nestedParams) ? nestedParams : undefined;
}

if (screen === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR) {
return getSearchRootParamsFromNestedNavigatorParams(nestedParams);
}

return undefined;
}

function getSearchRootParamsFromSearchNavigatorState(state: unknown): SearchRootParams | undefined {
const searchRootRoute = getLastRouteByName(state, SCREENS.SEARCH.ROOT);
const searchRootParams = getRouteParams(searchRootRoute);
return isSearchRootParams(searchRootParams) ? searchRootParams : undefined;
}

function getSearchRootParamsFromTabState(state: unknown): SearchRootParams | undefined {
const searchNavigatorRoute = getLastRouteByName(state, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR);
return getSearchRootParamsFromNestedNavigatorParams(getRouteParams(searchNavigatorRoute)) ?? getSearchRootParamsFromSearchNavigatorState(getRouteState(searchNavigatorRoute));
}

function getSearchQueryJSONFromRouteParams(params: unknown) {
if (!isSearchRootParams(params)) {
return undefined;
}

return buildSearchQueryJSON(params.q, params.rawQuery);
}

function getCurrentSearchQueryJSON() {
const rootState = navigationRef.getRootState();
const lastTabNavigator = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR);
const lastSearchNavigator = lastTabNavigator?.state?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR);
let lastSearchNavigatorState = lastSearchNavigator?.state;
const tabStateFromParams = getParamsState(lastTabNavigator?.params);
const tabState = lastTabNavigator?.state ?? (lastTabNavigator?.key ? getPreservedNavigatorState(lastTabNavigator.key) : undefined) ?? tabStateFromParams;
const lastSearchNavigator = getLastRouteByName(tabState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR);
let lastSearchNavigatorState = getRouteState(lastSearchNavigator);
if (!lastSearchNavigatorState) {
lastSearchNavigatorState = lastSearchNavigator?.key ? getPreservedNavigatorState(lastSearchNavigator?.key) : undefined;
const lastSearchNavigatorKey = getRouteKey(lastSearchNavigator);
lastSearchNavigatorState = lastSearchNavigatorKey ? getPreservedNavigatorState(lastSearchNavigatorKey) : undefined;
}

const nestedSearchRootParams =
getSearchRootParamsFromNestedNavigatorParams(getRouteParams(lastSearchNavigator)) ??
getSearchRootParamsFromNestedNavigatorParams(lastTabNavigator?.params) ??
getSearchRootParamsFromTabState(tabStateFromParams);

// When the SearchFullscreenNavigator has never been mounted (e.g. lazy tab not yet visited),
// neither .state nor the preserved state map will have an entry. Fall back to the default
// query that the navigator would use as its initialParams.
// neither .state nor the preserved state map will have an entry. Use nested route params when
// React Navigation provided them, otherwise fall back to the default initialParams query.
if (!lastSearchNavigatorState) {
const nestedQueryJSON = getSearchQueryJSONFromRouteParams(nestedSearchRootParams);
if (nestedQueryJSON) {
return nestedQueryJSON;
}
return buildSearchQueryJSON(buildSearchQueryString());
}

const lastSearchRoute = lastSearchNavigatorState.routes.findLast((route) => route.name === SCREENS.SEARCH.ROOT);
if (!lastSearchRoute?.params) {
return;
}

const {q: searchParams, rawQuery} = lastSearchRoute.params as SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH.ROOT];
const queryJSON = buildSearchQueryJSON(searchParams, rawQuery);
const lastSearchRoute = getLastRouteByName(lastSearchNavigatorState, SCREENS.SEARCH.ROOT);
const queryJSON = getSearchQueryJSONFromRouteParams(getRouteParams(lastSearchRoute));
if (!queryJSON) {
return;
}
Expand Down
14 changes: 9 additions & 5 deletions tests/ui/TimeExpenseConfirmationTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@ jest.mock('@components/ProductTrainingContext', () => ({
jest.mock('@src/hooks/useResponsiveLayout');

jest.mock('@libs/Navigation/navigationRef', () => ({
getCurrentRoute: jest.fn(() => ({
name: 'Money_Request_Step_Confirmation',
params: {},
})),
getState: jest.fn(() => ({})),
__esModule: true,
default: {
getCurrentRoute: jest.fn(() => ({
name: 'Money_Request_Step_Confirmation',
params: {},
})),
getState: jest.fn(() => ({})),
getRootState: jest.fn(() => ({routes: []})),
},
}));

jest.mock('@libs/Navigation/Navigation', () => {
Expand Down
14 changes: 9 additions & 5 deletions tests/ui/components/IOURequestStepConfirmationPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,15 @@ jest.mock('@libs/getCurrentPosition');
jest.mock('@libs/getIsNarrowLayout', () => jest.fn(() => false));

jest.mock('@libs/Navigation/navigationRef', () => ({
getCurrentRoute: jest.fn(() => ({
name: 'Money_Request_Step_Confirmation',
params: {},
})),
getState: jest.fn(() => ({})),
__esModule: true,
default: {
getCurrentRoute: jest.fn(() => ({
name: 'Money_Request_Step_Confirmation',
params: {},
})),
getState: jest.fn(() => ({})),
getRootState: jest.fn(() => ({routes: []})),
},
}));

jest.mock('@libs/Navigation/Navigation', () => {
Expand Down
17 changes: 15 additions & 2 deletions tests/unit/Navigation/getTopmostFullScreenRouteTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import NAVIGATORS from '@src/NAVIGATORS';

const mockGetRootState = jest.fn();

jest.mock('@libs/Navigation/Navigation', () => ({
navigationRef: {
jest.mock('@libs/Navigation/navigationRef', () => ({
__esModule: true,
default: {
getRootState: () => mockGetRootState() as unknown,
},
}));
Expand Down Expand Up @@ -33,6 +34,18 @@ describe('getTopmostFullScreenRoute', () => {
expect(getTopmostFullScreenRoute()).toBeUndefined();
});

it('does not use tab screen params as focused state', () => {
mockGetRootState.mockReturnValue({
routes: [
{
name: NAVIGATORS.TAB_NAVIGATOR,
params: {screen: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR},
},
],
});
expect(getTopmostFullScreenRoute()).toBeUndefined();
});

it('returns the focused tab route based on state.index', () => {
const reportsRoute = {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR};
const searchRoute = {name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR};
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/Search/SearchQueryUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
buildSearchQueryString,
buildUserReadableQueryString,
getAdvancedFiltersToReset,
getCurrentSearchQueryJSON,
getDateRangeDisplayValueFromFormValue,
getDisplayQueryFiltersForKey,
getFilterDisplayValue,
Expand All @@ -27,11 +28,22 @@ import {
shouldResetSortForViewChange,
sortOptionsWithEmptyValue,
} from '@src/libs/SearchQueryUtils';
import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
import SCREENS from '@src/SCREENS';
import type {SearchAdvancedFiltersForm} from '@src/types/form';
import type * as OnyxTypes from '@src/types/onyx';
import {localeCompare, translateLocal} from '../../utils/TestHelper';

const mockGetRootState = jest.fn();

jest.mock('@libs/Navigation/navigationRef', () => ({
__esModule: true,
default: {
getRootState: () => mockGetRootState() as unknown,
},
}));

const personalDetailsFakeData = {
'johndoe@example.com': {
accountID: 12345,
Expand Down Expand Up @@ -65,6 +77,59 @@ jest.mock('@libs/PersonalDetailsUtils', () => {
const defaultQuery = `type:expense sortBy:date sortOrder:desc`;

describe('SearchQueryUtils', () => {
beforeEach(() => {
mockGetRootState.mockReset();
});

describe('getCurrentSearchQueryJSON', () => {
test('reads nested Search params from an unmounted tab route', () => {
mockGetRootState.mockReturnValue({
routes: [
{
name: NAVIGATORS.TAB_NAVIGATOR,
params: {
screen: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR,
params: {
screen: SCREENS.SEARCH.ROOT,
params: {q: 'type:invoice'},
},
},
},
],
});

expect(getCurrentSearchQueryJSON()?.type).toBe(CONST.SEARCH.DATA_TYPES.INVOICE);
});

test('reads nested Search params from tab params state', () => {
mockGetRootState.mockReturnValue({
routes: [
{
name: NAVIGATORS.TAB_NAVIGATOR,
params: {
state: {
index: 2,
routes: [
{name: SCREENS.HOME},
{name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR},
{
name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR,
params: {
screen: SCREENS.SEARCH.ROOT,
params: {q: 'type:invoice'},
},
},
],
},
},
},
],
});

expect(getCurrentSearchQueryJSON()?.type).toBe(CONST.SEARCH.DATA_TYPES.INVOICE);
});
});

describe('getDateRangeDisplayValueFromFormValue', () => {
test('returns full range display when both boundaries exist', () => {
const result = getDateRangeDisplayValueFromFormValue('2025-03-01,2025-03-10');
Expand Down
Loading
Loading