diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index a82e4d144761..fc55f1fb5ca1 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -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'; @@ -82,12 +82,12 @@ function getOnboardingAdaptedState(state: PartialState): Partia return getRoutesWithIndex(routes); } -function getAdaptedState(state: PartialState>): GetAdaptedStateReturnType { +function getAdaptedState(state: PartialState): 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 | 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. @@ -95,7 +95,7 @@ function getAdaptedState(state: PartialState (r === fullScreenRoute ? {...r, state: tabState} : r)); currentState = {...currentState, routes: normalizedRoutes}; } @@ -104,19 +104,19 @@ function getAdaptedState(state: PartialState r.name === NAVIGATORS.WORKSPACE_NAVIGATOR); if (wsNavRoute) { - const wsNavState = wsNavRoute.state as PartialState | 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); } } @@ -126,8 +126,8 @@ function getAdaptedState(state: PartialState>; + 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. @@ -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>; + const state = getStateFromPath(normalizedPath); if (shouldReplacePathInNestedState) { replacePathInNestedState(state, normalizedPath); } diff --git a/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts b/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts index 10ee6caa4cad..e94211c0803e 100644 --- a/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts +++ b/src/libs/Navigation/helpers/getMatchingFullScreenRoute.ts @@ -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, @@ -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'; @@ -65,16 +65,14 @@ function isRouteWithBackToParam(route: NavigationRoute): route is Route * * @private - Internal helper. Do not export or use outside this file. */ +function popFocusedRoute(state: NavigationState): NavigationState | undefined; +function popFocusedRoute(state: PartialState): PartialState | 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]; @@ -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) { + 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. @@ -127,13 +140,13 @@ 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 | 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)) { @@ -141,7 +154,7 @@ function getPathFromStateWithDynamicRoute(state: State): string { 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}` : ''}`; @@ -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; diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index 9d2f1ff67ce1..1241a8c84272 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -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'; @@ -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 { +function getStateFromPath(path: string): PartialState { const normalizedPath = normalizePath(path); const normalizedPathAfterRedirection = getMatchingNewRoute(normalizedPath) ?? normalizedPath; diff --git a/src/libs/Navigation/helpers/hasDifferentSplitScope.ts b/src/libs/Navigation/helpers/hasDifferentSplitScope.ts index 4f6672e6a807..37df4a964652 100644 --- a/src/libs/Navigation/helpers/hasDifferentSplitScope.ts +++ b/src/libs/Navigation/helpers/hasDifferentSplitScope.ts @@ -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; } diff --git a/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts index 282bc6da85f8..5c46ef44d814 100644 --- a/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts +++ b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts @@ -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 { - 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, }; diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index 398a68898e57..dad5ba8614db 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -4,11 +4,10 @@ import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; import {getTabState} from '@libs/Navigation/helpers/tabNavigatorUtils'; import {config as linkingConfig} from '@libs/Navigation/linkingConfig/config'; -import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; -import {shallowCompare} from '@libs/ObjectUtils'; +import {isRecord, shallowCompare} from '@libs/ObjectUtils'; import getMatchingNewRoute from '@navigation/helpers/getMatchingNewRoute'; -import type {NavigationPartialRoute, ReportsSplitNavigatorParamList, RootNavigatorParamList, StackNavigationAction} from '@navigation/types'; +import type {NavigationPartialRoute, NavigationRoute, RootNavigatorParamList, StackNavigationAction} from '@navigation/types'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -20,9 +19,11 @@ import type {NavigationAction, NavigationContainerRef, NavigationState, PartialS import {getActionFromState} from '@react-navigation/core'; import {CommonActions, findFocusedRoute} from '@react-navigation/native'; -import type {ActionPayloadParams, LinkToOptions} from './types'; +import type {LinkToOptions} from './types'; -import getMinimalAction from './getMinimalAction'; +import getMinimalAction, {isNamedActionPayload} from './getMinimalAction'; + +type FullScreenRoute = Omit & Pick; const defaultLinkToOptions = { forceReplace: false, @@ -30,27 +31,19 @@ const defaultLinkToOptions = { skipMatchingFullScreenRoute: false, } satisfies Pick; -/** - * The split router reads `shouldSkipInitialSidebar` from the innermost screen params. Walk through nested - * navigator payloads so destinations with and without their own params receive the transient marker. - */ -function addSkipInitialSidebarParam(params: ActionPayloadParams | undefined): ActionPayloadParams { - const navigationParams = params ?? {}; - if (typeof navigationParams.screen === 'string') { +/** The split router reads this transient marker from the innermost screen params. */ +function addSkipInitialSidebarParam(params: unknown): Record { + const navigationParams = isRecord(params) ? params : {}; + if (typeof navigationParams.screen === 'string' && (navigationParams.params === undefined || isRecord(navigationParams.params))) { return {...navigationParams, params: addSkipInitialSidebarParam(navigationParams.params)}; } return {...navigationParams, shouldSkipInitialSidebar: true}; } -function hasParamsPayload(action: NavigationAction): action is NavigationAction & {payload: {params?: ActionPayloadParams}} { - return !!action.payload && 'params' in action.payload; -} - function addSkipInitialSidebarParamToAction(action: NavigationAction): NavigationAction { - if (!hasParamsPayload(action)) { + if (!isNamedActionPayload(action.payload)) { return action; } - return {...action, payload: {...action.payload, params: addSkipInitialSidebarParam(action.payload.params)}}; } @@ -60,17 +53,20 @@ function addSkipInitialSidebarParamToAction(action: NavigationAction): Navigatio */ const ROOT_TAB_SCREENS = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, SCREENS.INSIGHTS, SCREENS.SETTINGS.ROOT, SCREENS.WORKSPACES_LIST]); -function areNamesAndParamsEqual(currentState: NavigationState, stateFromPath: PartialState>) { +function areNamesAndParamsEqual(currentState: NavigationState, stateFromPath: PartialState) { const currentFocusedRoute = findFocusedRoute(currentState); const targetFocusedRoute = findFocusedRoute(stateFromPath); const areNamesEqual = currentFocusedRoute?.name === targetFocusedRoute?.name; - const areParamsEqual = shallowCompare(currentFocusedRoute?.params as Record | undefined, targetFocusedRoute?.params as Record | undefined); + const areParamsEqual = shallowCompare( + isRecord(currentFocusedRoute?.params) ? currentFocusedRoute.params : undefined, + isRecord(targetFocusedRoute?.params) ? targetFocusedRoute.params : undefined, + ); return areNamesEqual && areParamsEqual; } -function arePathAndBackToEqual(stateFromPath: PartialState>) { +function arePathAndBackToEqual(stateFromPath: PartialState) { const focusedRouteFromPath = findFocusedRoute(stateFromPath); const params = focusedRouteFromPath?.params ?? {}; @@ -93,15 +89,22 @@ function isNavigatingToAttachmentScreen(focusedRouteName?: string) { return focusedRouteName === SCREENS.REPORT_ATTACHMENTS; } +function getReportNavigationIDs(params: NavigationPartialRoute['params']) { + return { + reportID: params && 'reportID' in params ? params.reportID : undefined, + reportActionID: params && 'reportActionID' in params ? params.reportActionID : undefined, + }; +} + function isNavigatingToReportWithSameReportID(currentRoute: NavigationPartialRoute, newRoute: NavigationPartialRoute) { if (currentRoute.name !== SCREENS.REPORT || newRoute.name !== SCREENS.REPORT) { return false; } - const currentParams = currentRoute.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; - const newParams = newRoute?.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; + const currentParams = getReportNavigationIDs(currentRoute.params); + const newParams = getReportNavigationIDs(newRoute.params); - return currentParams?.reportID === newParams?.reportID; + return currentParams.reportID === newParams.reportID; } function isNavigatingToReportActionWithinSameReport(currentRoute: NavigationPartialRoute, newRoute: NavigationPartialRoute) { @@ -109,17 +112,17 @@ function isNavigatingToReportActionWithinSameReport(currentRoute: NavigationPart return false; } - const currentParams = currentRoute.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; - const newParams = newRoute?.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; + const currentParams = getReportNavigationIDs(currentRoute.params); + const newParams = getReportNavigationIDs(newRoute.params); - return currentParams?.reportID === newParams?.reportID && currentParams.reportActionID !== newParams.reportActionID; + return currentParams.reportID === newParams.reportID && currentParams.reportActionID !== newParams.reportActionID; } /** * Returns true when both current and target states are within TabNavigator (tab switching). * In this case we must keep NAVIGATE (not PUSH) because tab navigators use jumpTo/navigate. */ -function isSwitchingTabsWithinTabNavigator(currentState: NavigationState, stateFromPath: PartialState>) { +function isSwitchingTabsWithinTabNavigator(currentState: NavigationState, stateFromPath: PartialState) { const lastFullScreenRoute = currentState.routes.findLast((route) => isFullScreenName(route.name)); const targetFullScreenRoute = stateFromPath.routes?.findLast((route) => isFullScreenName(route.name)); @@ -130,7 +133,7 @@ function isSwitchingTabsWithinTabNavigator(currentState: NavigationState, - matchingFullScreenRoute: NavigationPartialRoute, - lastFullScreenRoute: NavigationPartialRoute, -) { +function shouldChangeToMatchingFullScreen(newFocusedRoute: ReturnType, matchingFullScreenRoute: FullScreenRoute, lastFullScreenRoute: FullScreenRoute) { if (matchingFullScreenRoute.name !== lastFullScreenRoute.name) { return true; } @@ -165,9 +164,7 @@ function shouldChangeToMatchingFullScreen( * Preserves nested split state under `params.state`, where React Navigation expects it. * Omitting it rebuilds the workspace split without its policy-scoped route history. */ -function getMatchingFullScreenRouteParams( - matchingFullScreenRoute: NavigationPartialRoute, -): NavigationPartialRoute['params'] | {screen: string; params: NavigationPartialRoute['params'] | undefined} { +function getMatchingFullScreenRouteParams(matchingFullScreenRoute: FullScreenRoute): FullScreenRoute['params'] | {screen: string; params: FullScreenRoute['params'] | undefined} { const lastRoute = matchingFullScreenRoute.state?.routes?.at(-1); if (!lastRoute) { return matchingFullScreenRoute.params; @@ -188,14 +185,14 @@ export default function linkTo(navigation: NavigationContainerRef>; - const currentState = navigation.getRootState() as PlatformStackNavigationState; + const stateFromPath = getStateFromPath(normalizedPathAfterRedirection); + const currentState = navigation.getRootState(); const focusedRouteFromPath = findFocusedRoute(stateFromPath); const currentFocusedRoute = findFocusedRoute(currentState); @@ -218,8 +215,6 @@ export default function linkTo(navigation: NavigationContainerRef route.name === NAVIGATORS.TAB_NAVIGATOR); - const tabState = tabRoute ? getTabState(tabRoute as NavigationPartialRoute) : undefined; + const tabState = tabRoute ? getTabState(tabRoute) : undefined; const tabNavigatorStateKey = tabRoute?.state?.key; - const lastFullScreenRoute = tabState?.routes?.at(tabState.index ?? 0) as NavigationPartialRoute | undefined; + const lastFullScreenRoute: FullScreenRoute | undefined = tabState?.routes?.at(tabState.index ?? 0); if (matchingFullScreenRoute && lastFullScreenRoute && shouldChangeToMatchingFullScreen(newFocusedRoute, matchingFullScreenRoute, lastFullScreenRoute)) { const matchingFullScreenRouteInTabRootState = tabState?.routes?.find((route) => route.name === matchingFullScreenRoute.name); if (matchingFullScreenRouteInTabRootState && matchingFullScreenRouteInTabRootState.state === undefined) { @@ -309,15 +304,11 @@ export default function linkTo(navigation: NavigationContainerRef>, path: string) { +function replacePathInNestedState(state: PartialState, path: string) { const found = findFocusedRoute(state); if (!found) { return; diff --git a/src/setup/addUtilsToWindow.ts b/src/setup/addUtilsToWindow.ts index e1147d5cd66b..59a0bbb8afc9 100644 --- a/src/setup/addUtilsToWindow.ts +++ b/src/setup/addUtilsToWindow.ts @@ -1,11 +1,13 @@ import {isProduction as isProductionLib} from '@libs/Environment/Environment'; import navigationRef from '@libs/Navigation/navigationRef'; +import {isRecord} from '@libs/ObjectUtils'; +import {getLinkedTransactionID} from '@libs/ReportActionsUtils'; import {setSupportAuthToken} from '@userActions/Session'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CollectionKeyBase} from 'react-native-onyx/dist/types'; +import type {OnyxKey} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -23,80 +25,85 @@ export default function addUtilsToWindow() { return; } - window.Onyx = Onyx as typeof Onyx & { - log: (key: CollectionKeyBase) => void; - }; - - window.Onyx.log = function (key: CollectionKeyBase) { + function log(key: OnyxKey): void { window.Onyx.get(key).then((value) => { /* eslint-disable-next-line no-console */ console.log(value); }); - }; + } + + window.Onyx = Onyx; + window.Onyx.log = log; window.setSupportToken = setSupportAuthToken; // Helper to get current route params - const getRouteParams = () => { - return navigationRef.current?.getCurrentRoute()?.params as Record | undefined; - }; + function getRouteParams() { + return navigationRef.current?.getCurrentRoute()?.params; + } + + function getStringRouteParam(params: unknown, key: 'reportID' | 'transactionID' | 'policyID'): string | undefined { + if (!isRecord(params)) { + return undefined; + } + const value = params[key]; + return typeof value === 'string' && value ? value : undefined; + } // Helper to get reportID from various sources - const getReportID = async (params: Record | undefined) => { - if (params?.reportID) { - return params.reportID; + async function getReportID(params: ReturnType): Promise { + const reportID = getStringRouteParam(params, 'reportID'); + if (reportID) { + return reportID; } - if (params?.transactionID) { - const transaction = await window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}` as CollectionKeyBase); - return (transaction as {reportID?: string} | undefined)?.reportID; + const transactionID = getStringRouteParam(params, 'transactionID'); + if (transactionID) { + const transaction = await window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + return transaction?.reportID; } return undefined; - }; + } // Helper to get transactionID from one expense report - const getTransactionIDFromReport = async (reportID: string) => { - const report = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}` as CollectionKeyBase); - const typedReport = report as {parentReportID?: string; parentReportActionID?: string} | undefined; + async function getTransactionIDFromReport(reportID: string): Promise { + const report = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); // First try: Get from parent report action (for transaction thread reports) - if (typedReport?.parentReportID && typedReport?.parentReportActionID) { - const parentReportActions = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${typedReport.parentReportID}` as CollectionKeyBase); - const parentAction = (parentReportActions as Record | undefined)?.[typedReport.parentReportActionID]; - if (parentAction?.originalMessage?.IOUTransactionID) { - return parentAction.originalMessage.IOUTransactionID; + if (report?.parentReportID && report?.parentReportActionID) { + const parentReportActions = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`); + const parentTransactionID = getLinkedTransactionID(parentReportActions?.[report.parentReportActionID]); + if (parentTransactionID) { + return parentTransactionID; } } // Fallback: Search the report's own report actions (for expense reports with one transaction) - const reportActions = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as CollectionKeyBase); - const actions = reportActions as Record | undefined; - if (actions) { - for (const action of Object.values(actions)) { - if (action?.originalMessage?.IOUTransactionID) { - return action.originalMessage.IOUTransactionID; - } + const reportActions = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); + for (const action of Object.values(reportActions ?? {})) { + const transactionID = getLinkedTransactionID(action); + if (transactionID) { + return transactionID; } } return undefined; - }; + } // Helper to get policyID from report (checks parent report for one expense reports) - const getPolicyIDFromReport = async (reportID: string) => { - const report = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}` as CollectionKeyBase); - const typedReport = report as {policyID?: string; parentReportID?: string} | undefined; + async function getPolicyIDFromReport(reportID: string): Promise { + const report = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - if (typedReport?.policyID) { - return typedReport.policyID; + if (report?.policyID) { + return report.policyID; } - if (typedReport?.parentReportID) { - const parentReport = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${typedReport.parentReportID}` as CollectionKeyBase); - return (parentReport as {policyID?: string} | undefined)?.policyID; + if (report?.parentReportID) { + const parentReport = await window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`); + return parentReport?.policyID; } return undefined; - }; + } // Define lazy getters for debug data Object.defineProperties(window, { @@ -105,15 +112,16 @@ export default function addUtilsToWindow() { get: async () => { const params = getRouteParams(); - if (params?.policyID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.POLICY}${params.policyID}` as CollectionKeyBase); + const routePolicyID = getStringRouteParam(params, 'policyID'); + if (routePolicyID) { + return window.Onyx.get(`${ONYXKEYS.COLLECTION.POLICY}${routePolicyID}`); } const reportID = await getReportID(params); if (reportID) { const policyID = await getPolicyIDFromReport(reportID); if (policyID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as CollectionKeyBase); + return window.Onyx.get(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); } } @@ -127,7 +135,7 @@ export default function addUtilsToWindow() { const reportID = await getReportID(params); if (reportID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}` as CollectionKeyBase); + return window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); } return undefined; @@ -138,14 +146,16 @@ export default function addUtilsToWindow() { get: async () => { const params = getRouteParams(); - if (params?.transactionID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}` as CollectionKeyBase); + const routeTransactionID = getStringRouteParam(params, 'transactionID'); + if (routeTransactionID) { + return window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${routeTransactionID}`); } - if (params?.reportID) { - const transactionID = await getTransactionIDFromReport(params.reportID); + const reportID = getStringRouteParam(params, 'reportID'); + if (reportID) { + const transactionID = await getTransactionIDFromReport(reportID); if (transactionID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as CollectionKeyBase); + return window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); } } @@ -155,7 +165,7 @@ export default function addUtilsToWindow() { receipt: { configurable: true, get: async () => { - const transaction = await (window as {transaction?: Promise<{receipt?: unknown}>}).transaction; + const transaction = await window.transaction; return transaction?.receipt; }, }, diff --git a/src/types/modules/react-native-onyx.d.ts b/src/types/modules/react-native-onyx.d.ts index c091f53bce96..3a0d910e53f2 100644 --- a/src/types/modules/react-native-onyx.d.ts +++ b/src/types/modules/react-native-onyx.d.ts @@ -16,7 +16,7 @@ declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { Onyx: typeof Onyx & { - log: (key: CollectionKeyBase) => void; + log?: (key: CollectionKeyBase) => void; }; } } diff --git a/tests/navigation/getPathFromStateTests.ts b/tests/navigation/getPathFromStateTests.ts index 8a904f825b03..73843eed6336 100644 --- a/tests/navigation/getPathFromStateTests.ts +++ b/tests/navigation/getPathFromStateTests.ts @@ -243,6 +243,84 @@ describe('getPathFromState', () => { expect(getPathFromState(state as PartialState)).toBe('/settings/wallet/verify-account'); }); + it('rebuilds a complete nested state after removing its focused dynamic route', () => { + // Given full router states at both levels, reducing the RHP must retain its full-state metadata. + const state = { + stale: false, + key: 'root', + type: 'stack', + index: 0, + routeNames: ['RHPNavigator'], + routes: [ + { + key: 'rhp', + name: 'RHPNavigator', + params: {source: 'wallet'}, + state: { + stale: false, + key: 'rhp-stack', + type: 'stack', + index: 1, + routeNames: ['WalletScreen', 'VerifyAccountScreen'], + routes: [ + {key: 'wallet', name: 'WalletScreen', path: '/settings/wallet'}, + {key: 'verify', name: 'VerifyAccountScreen'}, + ], + }, + }, + ], + } satisfies NavigationState; + const snapshot = JSON.stringify(state); + const [rhpRoute] = state.routes; + const [walletRoute] = rhpRoute.state.routes; + const reducedState = { + ...state, + routes: [ + { + ...rhpRoute, + state: {...rhpRoute.state, index: 0, routes: [walletRoute]}, + }, + ], + } satisfies NavigationState; + + // When production serialization peels off the dynamic suffix, it must rebuild the nested full state. + const result = getPathFromState(state); + + // Then RN receives exactly the reduced full tree and the caller's original tree remains intact. + expect(result).toBe('/settings/wallet/verify-account'); + expect(mockRNGetPathFromState).toHaveBeenCalledTimes(1); + expect(mockRNGetPathFromState).toHaveBeenCalledWith(reducedState, {}); + expect(JSON.stringify(state)).toBe(snapshot); + }); + + it('removes the focused middle sibling from a complete state and focuses the last survivor', () => { + // Given a full stack whose dynamic route is in the middle, route order differs from focus order. + const state = { + stale: false, + key: 'root', + type: 'stack', + index: 1, + routeNames: ['WalletScreen', 'FlagScreen', 'ReportScreen'], + routes: [ + {key: 'wallet', name: 'WalletScreen', path: '/settings/wallet'}, + {key: 'flag', name: 'FlagScreen', params: {reportID: '456', reportActionID: 'abc'}}, + {key: 'report', name: 'ReportScreen', params: {reportID: '123'}, path: '/r/123'}, + ], + } satisfies NavigationState; + const snapshot = JSON.stringify(state); + const [walletRoute, , reportRoute] = state.routes; + const reducedState = {...state, index: 1, routes: [walletRoute, reportRoute]} satisfies NavigationState; + + // When production serialization removes the focused route, the last remaining sibling supplies the base. + const result = getPathFromState(state); + + // Then both survivors retain their keys, params and paths and no input state is mutated. + expect(result).toBe('/r/123/flag/456/abc'); + expect(mockRNGetPathFromState).toHaveBeenCalledTimes(1); + expect(mockRNGetPathFromState).toHaveBeenCalledWith(reducedState, {}); + expect(JSON.stringify(state)).toBe(snapshot); + }); + it('two dynamic suffixes inside nested navigator', () => { const state = buildState([ { diff --git a/tests/unit/addUtilsToWindowTest.ts b/tests/unit/addUtilsToWindowTest.ts new file mode 100644 index 000000000000..237bd1843cb7 --- /dev/null +++ b/tests/unit/addUtilsToWindowTest.ts @@ -0,0 +1,446 @@ +import type * as Environment from '@libs/Environment/Environment'; +import {isProduction} from '@libs/Environment/Environment'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import type * as SessionActions from '@userActions/Session'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import addUtilsToWindow from '@src/setup/addUtilsToWindow'; +import type {Policy, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry, OnyxKey} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.unmock('react-native-onyx'); +jest.mock('@libs/Environment/Environment', () => ({ + ...jest.requireActual('@libs/Environment/Environment'), + isProduction: jest.fn(), +})); +jest.mock('@userActions/Session', () => ({ + ...jest.requireActual('@userActions/Session'), + setSupportAuthToken: jest.fn(), +})); +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + getCurrentRoute: jest.fn(), + get current() { + return this; + }, + }, +})); + +const mockGetCurrentRoute = jest.spyOn(navigationRef, 'getCurrentRoute'); +const debugWindowProperties = ['Onyx', 'setSupportToken', 'policy', 'report', 'transaction', 'receipt']; + +// These minimal persisted shapes follow buildOptimisticIOUReportAction and the transaction collection fixture. +// The modern object message and the legacy array/originalMessage pair are read by the real message accessor. +const iouAction = { + reportActionID: 'parent-action', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2026-10-22 12:00:00', +} satisfies ReportAction; +const modernTransaction = { + transactionID: 'modern', + reportID: 'report', + amount: 100, + created: '2026-10-22', + currency: CONST.CURRENCY.USD, + merchant: 'Coffee', + receipt: {filename: 'receipt.jpg'}, +} satisfies Transaction; +const legacyTransaction = {...modernTransaction, transactionID: 'legacy'} satisfies Transaction; +const fallbackTransaction = {...modernTransaction, transactionID: 'fallback'} satisfies Transaction; + +describe('addUtilsToWindow', () => { + const windowDescriptors = new Map(); + const onyxDescriptors = new Map(); + + beforeEach(async () => { + for (const key of debugWindowProperties) { + windowDescriptors.set(key, Object.getOwnPropertyDescriptor(window, key)); + } + for (const key of ['get', 'log']) { + onyxDescriptors.set(key, Object.getOwnPropertyDescriptor(Onyx, key)); + } + jest.clearAllMocks(); + jest.mocked(isProduction).mockResolvedValue(false); + mockGetCurrentRoute.mockReturnValue(undefined); + await Onyx.clear(); + await waitForBatchedUpdates(); + addUtilsToWindow(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + for (const [key, descriptor] of windowDescriptors) { + if (descriptor) { + Object.defineProperty(window, key, descriptor); + } else { + Reflect.deleteProperty(window, key); + } + } + for (const [key, descriptor] of onyxDescriptors) { + if (descriptor) { + Object.defineProperty(Onyx, key, descriptor); + } else { + Reflect.deleteProperty(Onyx, key); + } + } + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('leaves the existing debug installation untouched in production', async () => { + // Given a production environment, even existing development properties must not be replaced. + const descriptors = debugWindowProperties.map((key) => Object.getOwnPropertyDescriptor(window, key)); + jest.mocked(isProduction).mockResolvedValue(true); + + // When the asynchronous environment check completes, no debug properties should be installed. + addUtilsToWindow(); + await waitForBatchedUpdates(); + + // Then each property keeps its original descriptor, including the getter identity. + expect(debugWindowProperties.map((key) => Object.getOwnPropertyDescriptor(window, key))).toEqual(descriptors); + }); + + it('exposes the original native Onyx get without replacing it', () => { + // Given the native get descriptor saved before installation, the debug API should retain its value. + // When nonproduction setup exposes Onyx, it should expose the original object and read function. + const exposedGet = window.Onyx.get; + + // Then a replacement wrapper or copied API object cannot satisfy these identity checks. + expect(window.Onyx).toBe(Onyx); + expect(exposedGet).toEqual(expect.any(Function)); + expect(exposedGet).toBe(onyxDescriptors.get('get')?.value); + }); + + it('logs the value read by the native public get', async () => { + // Given a populated configured collection, log should print the value from a real native read. + const key = ONYXKEYS.COLLECTION.TRANSACTION; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, modernTransaction); + await waitForBatchedUpdates(); + const get = jest.spyOn(Onyx, 'get'); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + + // When the installed optional log member runs, retain its local reference without an assertion. + const log = window.Onyx.log; + log?.(key); + await waitForBatchedUpdates(); + + // Then both the read and the logged value prove the installed log body executed. + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledWith(key); + expect(consoleLog).toHaveBeenCalledTimes(1); + expect(consoleLog).toHaveBeenCalledWith({[`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction}); + }); + + it('reads a stored member through the native public get', async () => { + // Given an awaited member write, the native read should return its current stored value. + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey; + await Onyx.set(key, modernTransaction); + await waitForBatchedUpdates(); + + // When the exposed native read starts, its promise keeps the member value type. + const result = window.Onyx.get(key) satisfies Promise>; + const value = await result; + + // Then the exact transaction is returned without a debug subscription wrapper. + expect(value).toEqual(modernTransaction); + }); + + it.each([true, false])('reads a regular key through the native public get, present: %s', async (isPresent) => { + // Given a present or absent session, its creation date follows the Session action writer. + const storedSession = {accountID: 123, creationDate: 1} satisfies Session; + if (isPresent) { + await Onyx.set(ONYXKEYS.SESSION, storedSession); + } + await waitForBatchedUpdates(); + + // When the exposed native read starts, its promise keeps the regular-key value type. + const result = window.Onyx.get(ONYXKEYS.SESSION) satisfies Promise>; + const session = await result; + + // Then presence returns the exact session and absence remains undefined. + expect(session).toEqual(isPresent ? storedSession : undefined); + }); + + it('preserves absent member reads through the native public get', async () => { + // Given a missing member, its value is absent without losing the transaction return type. + // When the exposed native read starts, its promise keeps the member value type. + const result = window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}missing`) satisfies Promise>; + const missing = await result; + + // Then a missing member remains undefined rather than an empty collection. + expect(missing).toBeUndefined(); + }); + + it('preserves initialized empty, populated and empty-after-deletion collection-root snapshots', async () => { + // Given global Onyx initialization, the configured root is already known and empty before its first write. + // An unseeded/pre-init root is not reachable through this initialized public seam. + // When native reads follow awaited writes and deletion, each promise retains its collection category. + const initialResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; + const initial = await initialResult; + expect(initial).toEqual({}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, modernTransaction); + await waitForBatchedUpdates(); + + const populatedResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; + const populated = await populatedResult; + expect(populated).toEqual({[`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, null); + await waitForBatchedUpdates(); + + const emptyResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; + const empty = await emptyResult; + + // Then deletion returns the collection root to an empty object. + expect(empty).toEqual({}); + }); + + it.each([ + { + name: 'modern-only parent', + parentAction: {...iouAction, message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: 'modern'}} satisfies ReportAction, + expectedTransaction: modernTransaction, + }, + { + name: 'legacy-only parent', + parentAction: {...iouAction, originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'legacy'}} satisfies ReportAction, + expectedTransaction: legacyTransaction, + }, + { + name: 'legacy array-message parent', + parentAction: { + ...iouAction, + message: [{type: 'COMMENT', text: 'legacy'}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'legacy'}, + } satisfies ReportAction, + expectedTransaction: legacyTransaction, + }, + { + name: 'conflicting modern and legacy parent IDs', + parentAction: { + ...iouAction, + message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: 'modern'}, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'legacy'}, + } satisfies ReportAction, + expectedTransaction: modernTransaction, + }, + { + name: 'modern parent without an ID does not reuse its legacy ID', + parentAction: { + ...iouAction, + message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: ''}, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'legacy'}, + } satisfies ReportAction, + expectedTransaction: fallbackTransaction, + }, + { + name: 'empty parent ID falls through', + parentAction: {...iouAction, message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: ''}} satisfies ReportAction, + expectedTransaction: fallbackTransaction, + }, + { + name: 'non-IOU parent cannot supply a transaction', + parentAction: { + ...iouAction, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: 'modern'}, + } satisfies ReportAction, + expectedTransaction: fallbackTransaction, + }, + ])('uses the production linked-ID lookup for $name', async ({parentAction, expectedTransaction}) => { + // Given conflicting parent, legacy and fallback records, the real domain accessor decides which ID is eligible. + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params: {reportID: 'report'}}); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}report` satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', parentReportActionID: 'parent-action'} satisfies Report, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}parent` satisfies OnyxKey]: {[parentAction.reportActionID]: parentAction}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report` satisfies OnyxKey]: { + fallback: {...iouAction, reportActionID: 'fallback', message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: 'fallback'}}, + }, + [`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}legacy` satisfies OnyxKey]: legacyTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}fallback` satisfies OnyxKey]: fallbackTransaction, + }); + await waitForBatchedUpdates(); + + // When the installed getter reads the report, it must prefer an eligible parent over the report's own actions. + const transaction = await window.transaction; + + // Then both modern-message priority and legacy compatibility are observable on the returned production value. + expect(transaction).toEqual(expectedTransaction); + }); + + it.each(['modern', 'legacy'])('skips ineligible fallback actions and returns the first linked %s IOU', async (messageFormat) => { + // Given a missing parent action, fallback order includes non-IOU, missing-ID and empty-ID actions before two valid IOUs. + const firstAction = { + ...iouAction, + reportActionID: 'first', + ...(messageFormat === 'modern' + ? { + message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: 'modern'}, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'legacy'}, + } + : {message: [{type: 'COMMENT', text: 'legacy'}], originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'legacy'}}), + } satisfies ReportAction; + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params: {reportID: 'report'}}); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}report` satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', parentReportActionID: 'absent'} satisfies Report, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report` satisfies OnyxKey]: { + comment: {...iouAction, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: 'fallback'}}, + missing: {...iouAction, message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: ''}}, + empty: {...iouAction, message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: ''}}, + first: firstAction, + later: {...iouAction, message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: 'fallback'}}, + }, + [`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}legacy` satisfies OnyxKey]: legacyTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}fallback` satisfies OnyxKey]: fallbackTransaction, + }); + await waitForBatchedUpdates(); + + // When the real getter scans report actions, it uses the same shared accessor as the parent path. + const transaction = await window.transaction; + + // Then iteration order selects the first eligible IOU, and a non-IOU cannot win merely by carrying an ID. + expect(transaction).toEqual(messageFormat === 'modern' ? modernTransaction : legacyTransaction); + }); + + it.each(['missing report', 'missing actions', 'no linked action'])('returns no transaction for %s', async (scenario) => { + // Given an incomplete report lookup, no arbitrary action field should manufacture a transaction ID. + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params: {reportID: 'report'}}); + if (scenario !== 'missing report') { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}report`, {reportID: 'report'}); + } + if (scenario === 'no linked action') { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}report`, {missing: {...iouAction, message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: ''}}}); + } + await waitForBatchedUpdates(); + + // When the public getter exhausts the available sources, absence should remain absence. + const transaction = await window.transaction; + + // Then callers can distinguish an unavailable transaction from a valid stored transaction. + expect(transaction).toBeUndefined(); + }); + + it('keeps direct route IDs ahead of conflicting transaction, report and parent fallbacks', async () => { + // Given all three route IDs, each direct ID must outrank related records pointing somewhere else. + const params = {reportID: 'direct-report', transactionID: 'modern', policyID: 'direct-policy'}; + const report = {reportID: 'direct-report', policyID: 'report-policy', parentReportID: 'parent'} satisfies Report; + const policy = {id: 'direct-policy', name: 'Direct'} satisfies Partial; + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params}); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}direct-report` satisfies OnyxKey]: report, + [`${ONYXKEYS.COLLECTION.REPORT}report` satisfies OnyxKey]: {reportID: 'report', policyID: 'other-policy'}, + [`${ONYXKEYS.COLLECTION.REPORT}parent` satisfies OnyxKey]: {reportID: 'parent', policyID: 'parent-policy'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction, + [`${ONYXKEYS.COLLECTION.POLICY}direct-policy` satisfies OnyxKey]: policy, + [`${ONYXKEYS.COLLECTION.POLICY}report-policy` satisfies OnyxKey]: {id: 'report-policy'}, + [`${ONYXKEYS.COLLECTION.POLICY}parent-policy` satisfies OnyxKey]: {id: 'parent-policy'}, + }); + await waitForBatchedUpdates(); + + // When the installed getters resolve together, receipt must follow the directly selected transaction. + const values = await Promise.all([window.report, window.policy, window.transaction, window.receipt]); + + // Then each public getter exposes the direct record rather than a conflicting fallback. + expect(values).toEqual([report, policy, modernTransaction, modernTransaction.receipt]); + }); + + it.each([true, false])('resolves a transaction route through its report with own policy present: %s', async (hasOwnPolicy) => { + // Given a transaction-only route, report discovery precedes the report's own policy and then its parent's policy. + const ownPolicy = {id: 'own-policy'} satisfies Partial; + const parentPolicy = {id: 'parent-policy'} satisfies Partial; + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.TRANSACTION, params: {transactionID: 'modern'}}); + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction, + [`${ONYXKEYS.COLLECTION.REPORT}report` satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', ...(hasOwnPolicy ? {policyID: 'own-policy'} : {})}, + [`${ONYXKEYS.COLLECTION.REPORT}parent` satisfies OnyxKey]: {reportID: 'parent', policyID: 'parent-policy'}, + [`${ONYXKEYS.COLLECTION.POLICY}own-policy` satisfies OnyxKey]: ownPolicy, + [`${ONYXKEYS.COLLECTION.POLICY}parent-policy` satisfies OnyxKey]: parentPolicy, + }); + await waitForBatchedUpdates(); + + // When the public report and policy getters read through the transaction, both fallbacks use real Onyx values. + const report = await window.report; + const policy = await window.policy; + + // Then report selection and own-before-parent policy selection retain their existing priorities. + expect(report).toEqual({reportID: 'report', parentReportID: 'parent', ...(hasOwnPolicy ? {policyID: 'own-policy'} : {})}); + expect(policy).toEqual(hasOwnPolicy ? ownPolicy : parentPolicy); + }); + + it.each(['0', ' '])('preserves a nonempty route ID without coercion or trimming: %j', async (reportID) => { + // Given IDs that are unusual but nonempty strings, route lookup must preserve their exact bytes. + const report = {reportID} satisfies Report; + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params: {reportID}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + // When the public getter reads the current report, the helper must neither trim nor coerce the ID. + const result = await window.report; + + // Then the exact record is found, proving that the ID was accepted rather than silently treated as absent. + expect(result).toEqual(report); + }); + + it('returns absent data for absent params, empty params and empty route IDs', async () => { + // Given no route params, all lazy getters must tolerate the missing lookup context. + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.HOME}); + + // When route params progress from absent to empty to empty-string IDs, none is a usable ID. + const absent = await Promise.all([window.report, window.policy, window.transaction, window.receipt]); + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.UNLINK_LOGIN, params: {}}); + const empty = await Promise.all([window.report, window.policy, window.transaction, window.receipt]); + const params = {reportID: '', policyID: '', transactionID: ''}; + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params}); + const emptyIDs = await Promise.all([window.report, window.policy, window.transaction, window.receipt]); + + // Then every getter consistently returns undefined without a fabricated Onyx key. + expect(absent).toEqual([undefined, undefined, undefined, undefined]); + expect(empty).toEqual(absent); + expect(emptyIDs).toEqual(absent); + }); + + it('rejects route IDs that are not strings instead of coercing them into stored keys', async () => { + // Given malformed route IDs and matching string keys, accepting coercion would return the wrong records. + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}123` satisfies OnyxKey]: {reportID: '123'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}123` satisfies OnyxKey]: modernTransaction, + [`${ONYXKEYS.COLLECTION.POLICY}123` satisfies OnyxKey]: {id: '123'}, + }); + mockGetCurrentRoute.mockReturnValue({ + key: 'route', + name: SCREENS.DEBUG.REPORT, + // @ts-expect-error Historical malformed route params deliberately violate the framework's string ID contract. + params: {reportID: 123, transactionID: 123, policyID: 123}, + }); + await waitForBatchedUpdates(); + + // When the public getters inspect the malformed route, local record narrowing must still reject the ID values. + const values = await Promise.all([window.report, window.policy, window.transaction, window.receipt]); + + // Then no numeric ID is coerced to a string and no matching stored record is exposed. + expect(values).toEqual([undefined, undefined, undefined, undefined]); + }); + + it('returns absent data when valid direct IDs have no related Onyx records', async () => { + // Given valid route strings with missing storage entries, lookup absence is different from malformed params. + const params = {reportID: 'missing', transactionID: 'missing', policyID: 'missing'}; + mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params}); + + // When each public getter follows its normal direct lookup, a missing related entry must be tolerated. + const values = await Promise.all([window.report, window.policy, window.transaction, window.receipt]); + + // Then all exposed data remains absent, including the receipt that depends on the selected transaction. + expect(values).toEqual([undefined, undefined, undefined, undefined]); + }); +});