From f5d36203e24dda020672c4cca88087db258c1892 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 16 Jun 2026 16:17:07 +0200 Subject: [PATCH 01/12] fix: global create scan misidentifying Search context via fallback chain --- .../helpers/getTopmostFullScreenRoute.ts | 33 +++++++- .../helpers/navigateAfterExpenseCreate.ts | 19 +++-- .../getTopmostFullScreenRouteTest.ts | 5 +- tests/unit/getSubmitHandlerTest.ts | 15 ++++ .../isSearchTopmostFullScreenRouteTest.ts | 81 +++++++++++++++++++ 5 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 tests/unit/isSearchTopmostFullScreenRouteTest.ts diff --git a/src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts b/src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts index 9162613745f0..1842627e0c64 100644 --- a/src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts +++ b/src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts @@ -1,10 +1,16 @@ -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'; +import {getTabScreenParam} from './tabNavigatorUtils'; /** * 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 → params.screen hint. + * The params.screen fallback returns a minimal stub `{name}` with no key/state/params. + * Callers that need `.state` or `.key` should guard against undefined on those fields. */ function getTopmostFullScreenRoute(): NavigationRoute | undefined { const rootState = navigationRef.getRootState() as State; @@ -14,11 +20,30 @@ 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; + } + } + + const tabScreenParam = getTabScreenParam(topmostTabNavigatorRoute); + if (tabScreenParam) { + return {name: tabScreenParam}; + } + + return undefined; } export default getTopmostFullScreenRoute; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index af7a8cdd2386..b6aa7a89fa45 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -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; +} + /** * 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. @@ -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 = () => { diff --git a/tests/unit/Navigation/getTopmostFullScreenRouteTest.ts b/tests/unit/Navigation/getTopmostFullScreenRouteTest.ts index 6dc2760abe33..1da281d22058 100644 --- a/tests/unit/Navigation/getTopmostFullScreenRouteTest.ts +++ b/tests/unit/Navigation/getTopmostFullScreenRouteTest.ts @@ -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, }, })); diff --git a/tests/unit/getSubmitHandlerTest.ts b/tests/unit/getSubmitHandlerTest.ts index 790f4692eec7..6ccac37f4d0f 100644 --- a/tests/unit/getSubmitHandlerTest.ts +++ b/tests/unit/getSubmitHandlerTest.ts @@ -254,6 +254,21 @@ describe('getSubmitHandler', () => { ).toBe(SUBMIT_HANDLER.SEARCH_DISMISS); }); + it('returns DISMISS_TO_REPORT when canDismissFromSearch but Search is not topmost and destination is loaded', () => { + expect( + getSubmitHandler( + snap({ + isFromGlobalCreate: true, + canDismissFromSearch: true, + navigatesToDestinationReport: true, + isSearchTopmostFullScreen: false, + destinationReportID: 'report-1', + isDestinationReportLoaded: true, + }), + ), + ).toBe(SUBMIT_HANDLER.DISMISS_TO_REPORT); + }); + it('returns DISMISS_MODAL (via fast path) for navigatesToDestinationReport when not isFromGlobalCreate but destination is loaded', () => { expect( getSubmitHandler( diff --git a/tests/unit/isSearchTopmostFullScreenRouteTest.ts b/tests/unit/isSearchTopmostFullScreenRouteTest.ts new file mode 100644 index 000000000000..a9a301d79116 --- /dev/null +++ b/tests/unit/isSearchTopmostFullScreenRouteTest.ts @@ -0,0 +1,81 @@ +import {setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +const mockNavigationRef = { + getRootState: jest.fn(), +}; + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: mockNavigationRef, +})); + +const {default: isSearchTopmostFullScreenRoute}: {default: () => boolean} = jest.requireActual('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'); + +const tabState = { + stale: false as const, + type: 'tab', + key: 'tab-state-key', + index: 2, + routeNames: [SCREENS.HOME, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR], + routes: [ + {key: 'home-key', name: SCREENS.HOME}, + {key: 'reports-key', name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + {key: 'search-key', name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}, + ], +}; + +const rootStateWithTab = (state?: typeof tabState) => ({ + stale: false, + type: 'stack', + key: 'root-key', + index: 0, + routeNames: [NAVIGATORS.TAB_NAVIGATOR], + routes: [ + { + key: 'tab-key', + name: NAVIGATORS.TAB_NAVIGATOR, + state, + }, + ], +}); + +const rootStateWithTabScreenParam = () => ({ + stale: false, + type: 'stack', + key: 'root-key', + index: 0, + routeNames: [NAVIGATORS.TAB_NAVIGATOR], + routes: [ + { + key: 'tab-key-without-preserved-state', + name: NAVIGATORS.TAB_NAVIGATOR, + params: {screen: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}, + }, + ], +}); + +describe('isSearchTopmostFullScreenRoute', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigationRef.getRootState.mockReturnValue(rootStateWithTab(tabState)); + setPreservedNavigatorState('tab-key', tabState); + }); + + it('returns true when live tab state has Search active', () => { + expect(isSearchTopmostFullScreenRoute()).toBe(true); + }); + + it('returns true from preserved tab state when live tab state is missing', () => { + mockNavigationRef.getRootState.mockReturnValue(rootStateWithTab(undefined)); + + expect(isSearchTopmostFullScreenRoute()).toBe(true); + }); + + it('returns true from tab screen params when live and preserved tab state are missing', () => { + mockNavigationRef.getRootState.mockReturnValue(rootStateWithTabScreenParam()); + + expect(isSearchTopmostFullScreenRoute()).toBe(true); + }); +}); From 8bcd72eabdfe3fa29cf4ae5709a1d1dea0b3cd0b Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 16 Jun 2026 17:12:44 +0200 Subject: [PATCH 02/12] address codex comment --- src/libs/SearchQueryUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index b0acef984347..f32abb457384 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1919,7 +1919,8 @@ function getQueryWithUpdatedValues(query: string, shouldSkipAmountConversion = f 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); + const tabState = lastTabNavigator?.state ?? (lastTabNavigator?.key ? getPreservedNavigatorState(lastTabNavigator.key) : undefined); + const lastSearchNavigator = tabState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); let lastSearchNavigatorState = lastSearchNavigator?.state; if (!lastSearchNavigatorState) { lastSearchNavigatorState = lastSearchNavigator?.key ? getPreservedNavigatorState(lastSearchNavigator?.key) : undefined; From bbe24fcfa8f2c37de2207d260f87e76251239af3 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 16 Jun 2026 17:12:55 +0200 Subject: [PATCH 03/12] fix: failing tests --- tests/ui/TimeExpenseConfirmationTest.tsx | 14 +++++++++----- .../IOURequestStepConfirmationPageTest.tsx | 14 +++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/ui/TimeExpenseConfirmationTest.tsx b/tests/ui/TimeExpenseConfirmationTest.tsx index 7a2bc8376319..faebe1c0500e 100644 --- a/tests/ui/TimeExpenseConfirmationTest.tsx +++ b/tests/ui/TimeExpenseConfirmationTest.tsx @@ -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', () => { diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 5664e457666a..c1c0f3b8b1d6 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -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', () => { From 00dbfdaae28593c6bd0aa85cbd697d873a042e98 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 17 Jun 2026 12:02:22 +0200 Subject: [PATCH 04/12] fix: nested Search query fallback for unmounted tab state --- src/libs/SearchQueryUtils.ts | 54 +++++++++++++++++++---- tests/unit/Search/SearchQueryUtilsTest.ts | 37 ++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index f32abb457384..2fb2b6a666aa 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -56,6 +56,7 @@ import {hashText} from './UserUtils'; import {isValidDate} from './ValidationUtils'; type FilterKeys = keyof typeof CONST.SEARCH.SYNTAX_FILTER_KEYS; +type SearchRootParams = SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH.ROOT]; // This map contains chars that match each operator const operatorToCharMap = { @@ -1916,6 +1917,42 @@ 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 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 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); @@ -1926,20 +1963,21 @@ function getCurrentSearchQueryJSON() { lastSearchNavigatorState = lastSearchNavigator?.key ? getPreservedNavigatorState(lastSearchNavigator?.key) : undefined; } + const nestedSearchRootParams = getSearchRootParamsFromNestedNavigatorParams(lastSearchNavigator?.params) ?? getSearchRootParamsFromNestedNavigatorParams(lastTabNavigator?.params); + // 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 queryJSON = getSearchQueryJSONFromRouteParams(lastSearchRoute?.params); if (!queryJSON) { return; } diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 985719190b7d..40dd8cd26b41 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -15,6 +15,7 @@ import { buildSearchQueryString, buildUserReadableQueryString, getAdvancedFiltersToReset, + getCurrentSearchQueryJSON, getDateRangeDisplayValueFromFormValue, getDisplayQueryFiltersForKey, getFilterDisplayValue, @@ -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, @@ -65,6 +77,31 @@ 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); + }); + }); + describe('getDateRangeDisplayValueFromFormValue', () => { test('returns full range display when both boundaries exist', () => { const result = getDateRangeDisplayValueFromFormValue('2025-03-01,2025-03-10'); From e6999a290d46e417dbbeb221f5bcbcca6cee7956 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 17 Jun 2026 12:25:50 +0200 Subject: [PATCH 05/12] fix: Search context detection from preserved tab state --- .../Navigation/helpers/getTopmostFullScreenRoute.ts | 10 +--------- .../unit/Navigation/getTopmostFullScreenRouteTest.ts | 12 ++++++++++++ tests/unit/isSearchTopmostFullScreenRouteTest.ts | 4 ++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts b/src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts index 1842627e0c64..0f4a30d5f617 100644 --- a/src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts +++ b/src/libs/Navigation/helpers/getTopmostFullScreenRoute.ts @@ -2,15 +2,12 @@ import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSp import navigationRef from '@libs/Navigation/navigationRef'; import type {NavigationRoute, RootNavigatorParamList, State} from '@libs/Navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; -import {getTabScreenParam} from './tabNavigatorUtils'; /** * 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 → params.screen hint. - * The params.screen fallback returns a minimal stub `{name}` with no key/state/params. - * Callers that need `.state` or `.key` should guard against undefined on those fields. + * Fallback chain: live tab state → preserved state. */ function getTopmostFullScreenRoute(): NavigationRoute | undefined { const rootState = navigationRef.getRootState() as State; @@ -38,11 +35,6 @@ function getTopmostFullScreenRoute(): NavigationRoute | undefined { } } - const tabScreenParam = getTabScreenParam(topmostTabNavigatorRoute); - if (tabScreenParam) { - return {name: tabScreenParam}; - } - return undefined; } diff --git a/tests/unit/Navigation/getTopmostFullScreenRouteTest.ts b/tests/unit/Navigation/getTopmostFullScreenRouteTest.ts index 1da281d22058..09daa51309ad 100644 --- a/tests/unit/Navigation/getTopmostFullScreenRouteTest.ts +++ b/tests/unit/Navigation/getTopmostFullScreenRouteTest.ts @@ -34,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}; diff --git a/tests/unit/isSearchTopmostFullScreenRouteTest.ts b/tests/unit/isSearchTopmostFullScreenRouteTest.ts index a9a301d79116..3762e1131fd7 100644 --- a/tests/unit/isSearchTopmostFullScreenRouteTest.ts +++ b/tests/unit/isSearchTopmostFullScreenRouteTest.ts @@ -73,9 +73,9 @@ describe('isSearchTopmostFullScreenRoute', () => { expect(isSearchTopmostFullScreenRoute()).toBe(true); }); - it('returns true from tab screen params when live and preserved tab state are missing', () => { + it('returns false from tab screen params when live and preserved tab state are missing', () => { mockNavigationRef.getRootState.mockReturnValue(rootStateWithTabScreenParam()); - expect(isSearchTopmostFullScreenRoute()).toBe(true); + expect(isSearchTopmostFullScreenRoute()).toBe(false); }); }); From 9c0f2a137a827744d04daa71e5057c90fa9a2648 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 17 Jun 2026 12:52:17 +0200 Subject: [PATCH 06/12] read Search params from tab params.state --- src/libs/SearchQueryUtils.ts | 73 ++++++++++++++++++++--- tests/unit/Search/SearchQueryUtilsTest.ts | 28 +++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 2fb2b6a666aa..f14faada80dc 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -57,6 +57,12 @@ 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; +}; // This map contains chars that match each operator const operatorToCharMap = { @@ -1927,6 +1933,43 @@ function isSearchRootParams(params: unknown): params is SearchRootParams { ); } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} + +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; @@ -1945,6 +1988,17 @@ function getSearchRootParamsFromNestedNavigatorParams(params: unknown): SearchRo 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; @@ -1956,14 +2010,19 @@ function getSearchQueryJSONFromRouteParams(params: unknown) { function getCurrentSearchQueryJSON() { const rootState = navigationRef.getRootState(); const lastTabNavigator = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); - const tabState = lastTabNavigator?.state ?? (lastTabNavigator?.key ? getPreservedNavigatorState(lastTabNavigator.key) : undefined); - const lastSearchNavigator = tabState?.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(lastSearchNavigator?.params) ?? getSearchRootParamsFromNestedNavigatorParams(lastTabNavigator?.params); + 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. Use nested route params when @@ -1976,8 +2035,8 @@ function getCurrentSearchQueryJSON() { return buildSearchQueryJSON(buildSearchQueryString()); } - const lastSearchRoute = lastSearchNavigatorState.routes.findLast((route) => route.name === SCREENS.SEARCH.ROOT); - const queryJSON = getSearchQueryJSONFromRouteParams(lastSearchRoute?.params); + const lastSearchRoute = getLastRouteByName(lastSearchNavigatorState, SCREENS.SEARCH.ROOT); + const queryJSON = getSearchQueryJSONFromRouteParams(getRouteParams(lastSearchRoute)); if (!queryJSON) { return; } diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 40dd8cd26b41..bd80453dae9f 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -100,6 +100,34 @@ describe('SearchQueryUtils', () => { 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', () => { From d426b52dde7cf83b3935fb1114d8bfb74a5e95e9 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 18 Jun 2026 12:50:56 +0200 Subject: [PATCH 07/12] Move isRecord to ObjectUtils.ts --- src/libs/ImportOnyxStateUtils.ts | 5 +---- src/libs/ObjectUtils.ts | 6 +++++- src/libs/SearchQueryUtils.ts | 5 +---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/libs/ImportOnyxStateUtils.ts b/src/libs/ImportOnyxStateUtils.ts index 9ec815c5ec35..cf6b39967a17 100644 --- a/src/libs/ImportOnyxStateUtils.ts +++ b/src/libs/ImportOnyxStateUtils.ts @@ -6,14 +6,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxState from '@src/types/onyx/OnyxState'; import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; import {clearOnyxStateBeforeImport, importOnyxCollectionState, importOnyxRegularState} from './actions/ImportOnyxState'; +import {isRecord} from './ObjectUtils'; // List of Onyx keys from the .txt file we want to keep for the local override const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.PREFERRED_THEME, ...Object.values(ONYXKEYS.DERIVED)]; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && !Array.isArray(value) && value !== null; -} - function transformNumericKeysToArray(data: UnknownRecord): UnknownRecord | unknown[] { const dataCopy = cloneDeep(data); if (!isRecord(dataCopy)) { diff --git a/src/libs/ObjectUtils.ts b/src/libs/ObjectUtils.ts index 1f3fe18fb153..c0d8de74482c 100644 --- a/src/libs/ObjectUtils.ts +++ b/src/libs/ObjectUtils.ts @@ -28,4 +28,8 @@ function filterObject>(obj: TObject, pre }, {} as TObject); } -export {shallowCompare, filterObject}; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export {shallowCompare, filterObject, isRecord}; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index f14faada80dc..a87f327c1418 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -46,6 +46,7 @@ import {validateAmount} from './MoneyRequestUtils'; import {getPreservedNavigatorState} from './Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import navigationRef from './Navigation/navigationRef'; import type {SearchFullscreenNavigatorParamList} from './Navigation/types'; +import {isRecord} from './ObjectUtils'; import {getDisplayNameOrDefault, getPersonalDetailByEmail} from './PersonalDetailsUtils'; import {getCleanedTagName} from './PolicyUtils'; import {getReportName} from './ReportNameUtils'; @@ -1933,10 +1934,6 @@ function isSearchRootParams(params: unknown): params is SearchRootParams { ); } -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - function isUnknownArray(value: unknown): value is unknown[] { return Array.isArray(value); } From e85fa350877f3e76504e801aaf02b5cc6459ee6f Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 18 Jun 2026 12:59:45 +0200 Subject: [PATCH 08/12] Use the new improved logic in getNavigateAfterCreateSearchNavigatorState --- src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts index b6aa7a89fa45..8a991dbfe6df 100644 --- a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -1,6 +1,7 @@ import {addPendingNewTransactionIDs} from '@libs/actions/IOU/PendingNewTransactions'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; +import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; @@ -24,8 +25,10 @@ type NavigateAfterExpenseCreateParams = { function getNavigateAfterCreateSearchNavigatorState() { const rootState = navigationRef.getRootState(); - const searchNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); - return searchNavigatorRoute?.state; + const tabNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); + const tabState = tabNavigatorRoute?.state ?? (tabNavigatorRoute?.key ? getPreservedNavigatorState(tabNavigatorRoute.key) : undefined); + const searchNavigatorRoute = tabState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + return searchNavigatorRoute?.state ?? (searchNavigatorRoute?.key ? getPreservedNavigatorState(searchNavigatorRoute.key) : undefined); } /** From 24ff2be241a1bb8802c449b829d4921d87a4c16a Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 18 Jun 2026 13:10:20 +0200 Subject: [PATCH 09/12] Modify NavigationRouteLike type and remove unnecessary accessor utils --- src/libs/SearchQueryUtils.ts | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index a87f327c1418..bc827c5a2c91 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -59,8 +59,8 @@ 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; + key?: string; + name?: string; params?: unknown; state?: unknown; }; @@ -1938,18 +1938,6 @@ function isUnknownArray(value: unknown): value is unknown[] { return Array.isArray(value); } -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; } @@ -1987,13 +1975,13 @@ function getSearchRootParamsFromNestedNavigatorParams(params: unknown): SearchRo function getSearchRootParamsFromSearchNavigatorState(state: unknown): SearchRootParams | undefined { const searchRootRoute = getLastRouteByName(state, SCREENS.SEARCH.ROOT); - const searchRootParams = getRouteParams(searchRootRoute); + const searchRootParams = searchRootRoute?.params; 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)); + return getSearchRootParamsFromNestedNavigatorParams(searchNavigatorRoute?.params) ?? getSearchRootParamsFromSearchNavigatorState(searchNavigatorRoute?.state); } function getSearchQueryJSONFromRouteParams(params: unknown) { @@ -2010,14 +1998,13 @@ function getCurrentSearchQueryJSON() { 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); + let lastSearchNavigatorState = lastSearchNavigator?.state; if (!lastSearchNavigatorState) { - const lastSearchNavigatorKey = getRouteKey(lastSearchNavigator); - lastSearchNavigatorState = lastSearchNavigatorKey ? getPreservedNavigatorState(lastSearchNavigatorKey) : undefined; + lastSearchNavigatorState = lastSearchNavigator?.key ? getPreservedNavigatorState(lastSearchNavigator.key) : undefined; } const nestedSearchRootParams = - getSearchRootParamsFromNestedNavigatorParams(getRouteParams(lastSearchNavigator)) ?? + getSearchRootParamsFromNestedNavigatorParams(lastSearchNavigator?.params) ?? getSearchRootParamsFromNestedNavigatorParams(lastTabNavigator?.params) ?? getSearchRootParamsFromTabState(tabStateFromParams); @@ -2033,7 +2020,7 @@ function getCurrentSearchQueryJSON() { } const lastSearchRoute = getLastRouteByName(lastSearchNavigatorState, SCREENS.SEARCH.ROOT); - const queryJSON = getSearchQueryJSONFromRouteParams(getRouteParams(lastSearchRoute)); + const queryJSON = getSearchQueryJSONFromRouteParams(lastSearchRoute?.params); if (!queryJSON) { return; } From 223695f5900cee2076d81d1e37f75346342831a3 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 18 Jun 2026 13:28:15 +0200 Subject: [PATCH 10/12] Make tabState a factory function --- tests/unit/isSearchTopmostFullScreenRouteTest.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/isSearchTopmostFullScreenRouteTest.ts b/tests/unit/isSearchTopmostFullScreenRouteTest.ts index 3762e1131fd7..17ff48d21873 100644 --- a/tests/unit/isSearchTopmostFullScreenRouteTest.ts +++ b/tests/unit/isSearchTopmostFullScreenRouteTest.ts @@ -13,7 +13,7 @@ jest.mock('@libs/Navigation/navigationRef', () => ({ const {default: isSearchTopmostFullScreenRoute}: {default: () => boolean} = jest.requireActual('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'); -const tabState = { +const makeTabState = () => ({ stale: false as const, type: 'tab', key: 'tab-state-key', @@ -24,9 +24,9 @@ const tabState = { {key: 'reports-key', name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, {key: 'search-key', name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}, ], -}; +}); -const rootStateWithTab = (state?: typeof tabState) => ({ +const rootStateWithTab = (state?: ReturnType) => ({ stale: false, type: 'stack', key: 'root-key', @@ -59,6 +59,7 @@ const rootStateWithTabScreenParam = () => ({ describe('isSearchTopmostFullScreenRoute', () => { beforeEach(() => { jest.clearAllMocks(); + const tabState = makeTabState(); mockNavigationRef.getRootState.mockReturnValue(rootStateWithTab(tabState)); setPreservedNavigatorState('tab-key', tabState); }); From d7e0f34bb156ba1d5171f13b3d4329c27ff753f2 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 18 Jun 2026 13:31:56 +0200 Subject: [PATCH 11/12] Update params type --- src/libs/SearchQueryUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index bc827c5a2c91..159fde3a7475 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -61,7 +61,7 @@ type SearchRootParams = SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH type NavigationRouteLike = { key?: string; name?: string; - params?: unknown; + params?: Record; state?: unknown; }; From 9491ef38d9b1cee4230d3b6f90ae9cfb44af607d Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 18 Jun 2026 14:17:03 +0200 Subject: [PATCH 12/12] Add jsdoc and more tests --- src/libs/SearchQueryUtils.ts | 8 ++ tests/unit/Search/SearchQueryUtilsTest.ts | 113 ++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 159fde3a7475..371eed7f5a62 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -59,9 +59,13 @@ import {isValidDate} from './ValidationUtils'; type FilterKeys = keyof typeof CONST.SEARCH.SYNTAX_FILTER_KEYS; type SearchRootParams = SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH.ROOT]; type NavigationRouteLike = { + /** Unique React Navigation route identifier. */ key?: string; + /** Screen name as registered in the navigator. */ name?: string; + /** Screen-specific params passed to the route. */ params?: Record; + /** Nested navigator state, if this route is itself a navigator. */ state?: unknown; }; @@ -2322,6 +2326,10 @@ export { getDateModifierTitle, applyContainsOperatorToTextFields, serializeQueryJSONForBackend, + getLastRouteByName, + getParamsState, + getRoutes, + isSearchRootParams, }; export type {BuildUserReadableQueryStringParams}; diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index bd80453dae9f..8882da126b7f 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -20,8 +20,12 @@ import { getDisplayQueryFiltersForKey, getFilterDisplayValue, getKeywordQueryWithCurrentSearchContext, + getLastRouteByName, + getParamsState, getQueryWithUpdatedValues, getRangeBoundariesFromFormValue, + getRoutes, + isSearchRootParams, serializeQueryJSONForBackend, shouldHighlight, shouldResetSort, @@ -101,6 +105,36 @@ describe('SearchQueryUtils', () => { expect(getCurrentSearchQueryJSON()?.type).toBe(CONST.SEARCH.DATA_TYPES.INVOICE); }); + test('reads query from a mounted search navigator state', () => { + mockGetRootState.mockReturnValue({ + routes: [ + { + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + index: 2, + routes: [ + {name: SCREENS.HOME}, + {name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + { + name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, + state: { + routes: [ + { + name: 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: [ @@ -130,6 +164,85 @@ describe('SearchQueryUtils', () => { }); }); + describe('getLastRouteByName', () => { + const state = { + routes: [ + {key: 'home-key', name: SCREENS.HOME}, + {key: 'search-key-1', name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}, + {key: 'reports-key', name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR}, + {key: 'search-key-2', name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}, + ], + }; + + it('returns the last matching route', () => { + expect(getLastRouteByName(state, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.key).toBe('search-key-2'); + }); + + it('returns undefined when no route matches', () => { + expect(getLastRouteByName(state, SCREENS.SETTINGS.ROOT)).toBeUndefined(); + }); + + it('returns undefined for non-object state', () => { + expect(getLastRouteByName(null, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)).toBeUndefined(); + expect(getLastRouteByName('invalid', NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)).toBeUndefined(); + }); + }); + + describe('getRoutes', () => { + it('returns the routes array from a valid state', () => { + const routes = [{key: 'a', name: SCREENS.HOME}]; + expect(getRoutes({routes})).toBe(routes); + }); + + it('returns undefined when routes is not an array', () => { + expect(getRoutes({routes: 'invalid'})).toBeUndefined(); + }); + + it('returns undefined for non-object state', () => { + expect(getRoutes(null)).toBeUndefined(); + expect(getRoutes('invalid')).toBeUndefined(); + }); + }); + + describe('getParamsState', () => { + it('returns the state property from a record', () => { + const nestedState = {routes: []}; + expect(getParamsState({state: nestedState})).toBe(nestedState); + }); + + it('returns undefined for non-object params', () => { + expect(getParamsState(null)).toBeUndefined(); + expect(getParamsState('invalid')).toBeUndefined(); + }); + }); + + describe('isSearchRootParams', () => { + it('returns true for valid params with only q', () => { + expect(isSearchRootParams({q: 'type:expense'})).toBe(true); + }); + + it('returns true for valid params with q and rawQuery', () => { + expect(isSearchRootParams({q: 'type:expense', rawQuery: 'expense'})).toBe(true); + }); + + it('returns false when q is missing', () => { + expect(isSearchRootParams({rawQuery: 'expense'})).toBe(false); + }); + + it('returns false when q is not a string', () => { + expect(isSearchRootParams({q: 42})).toBe(false); + }); + + it('returns false when rawQuery is present but not a string', () => { + expect(isSearchRootParams({q: 'type:expense', rawQuery: 42})).toBe(false); + }); + + it('returns false for non-object input', () => { + expect(isSearchRootParams(null)).toBe(false); + expect(isSearchRootParams('string')).toBe(false); + }); + }); + describe('getDateRangeDisplayValueFromFormValue', () => { test('returns full range display when both boundaries exist', () => { const result = getDateRangeDisplayValueFromFormValue('2025-03-01,2025-03-10');