From efaca91dd8c1f4652aa31c2538b32cf934ff82f2 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:25:37 +0000 Subject: [PATCH 1/5] Remove unsafe assertions from navigation and debug helpers Seatbelt-Operation: s08-43192ddd-4541881-sign-1 --- .../helpers/getAdaptedStateFromPath.ts | 35 ++--- .../Navigation/helpers/getPathFromState.ts | 49 ++++--- .../Navigation/helpers/getStateFromPath.ts | 3 +- .../helpers/linkTo/getMinimalAction.ts | 10 +- src/libs/Navigation/helpers/linkTo/index.ts | 82 ++++++------ src/setup/addUtilsToWindow.ts | 120 ++++++++++-------- 6 files changed, 160 insertions(+), 139 deletions(-) diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index 2362f71b5cc9..f670ec37314f 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -1,5 +1,6 @@ import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; import TAB_SCREENS from '@libs/Navigation/AppNavigator/Navigators/TAB_SCREENS'; +import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; import { RHP_TO_DOMAIN, RHP_TO_HOME, @@ -10,18 +11,18 @@ import { RHP_TO_WORKSPACE, RHP_TO_WORKSPACES_LIST, } from '@libs/Navigation/linkingConfig/RELATIONS'; -import type {NavigationPartialRoute, NavigationRoute, RootNavigatorParamList} from '@libs/Navigation/types'; +import type {NavigationPartialRoute, NavigationRoute} from '@libs/Navigation/types'; +import {hasKey} from '@libs/ObjectUtils'; import {getReportOrDraftReport} from '@libs/ReportUtils'; 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, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; import pick from 'lodash/pick'; import buildTabNavigatorNestedState from './buildTabNavigatorNestedState'; @@ -36,7 +37,6 @@ import getParamsFromRoute from './getParamsFromRoute'; import getStateFromPath from './getStateFromPath'; import {isFullScreenName} from './isNavigatorName'; import normalizePath from './normalizePath'; -import replacePathInNestedState from './replacePathInNestedState'; type GetAdaptedStateReturnType = ReturnType; @@ -89,14 +89,14 @@ function isRouteWithReportID(route: NavigationRoute): route is Route): 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. @@ -327,7 +327,7 @@ function getAdaptedState(state: PartialState (r === fullScreenRoute ? {...r, state: tabState} : r)); currentState = {...currentState, routes: normalizedRoutes}; } @@ -336,19 +336,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); } } @@ -358,8 +358,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. @@ -432,9 +432,12 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR let normalizedPath = !path.startsWith('/') ? `/${path}` : path; normalizedPath = getMatchingNewRoute(normalizedPath) ?? normalizedPath; - const state = getStateFromPath(normalizedPath as RoutePath) as PartialState>; + const state = getStateFromPath(normalizedPath); if (shouldReplacePathInNestedState) { - replacePathInNestedState(state, normalizedPath); + const focusedRoute = findFocusedRoute(state); + if (focusedRoute) { + focusedRoute.path = normalizedPath; + } } if (state === undefined) { diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index 9a47860057d1..168dbf1c2a8c 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -1,8 +1,9 @@ import Log from '@libs/Log'; import {config, normalizedConfigs, screensWithOnyxTabNavigator} from '@libs/Navigation/linkingConfig/config'; import type {State} from '@libs/Navigation/types'; +import {hasKey} from '@libs/ObjectUtils'; -import type {Screen} from '@src/SCREENS'; +import type {NavigationState, PartialState} from '@react-navigation/native'; import {getPathFromState as RNGetPathFromState} from '@react-navigation/native'; @@ -11,10 +12,6 @@ import isDynamicRouteScreen from './dynamicRoutesUtils/isDynamicRouteScreen'; import splitPathAndQuery from './dynamicRoutesUtils/splitPathAndQuery'; import findFocusedRouteWithOnyxTabGuard from './findFocusedRouteWithOnyxTabGuard'; -function isScreen(name: string): name is Screen { - return name in normalizedConfigs; -} - /** * Resolves a single path segment: if it's a `:param` placeholder, replaces it * with the URL-encoded value from `params`; otherwise returns the segment as-is. @@ -80,6 +77,9 @@ function buildSuffixFromPattern(pattern: string, params: Record * * @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]; @@ -92,21 +92,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. @@ -125,13 +138,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, focusedRoute?.params ? {...focusedRoute.params} : undefined); // If this dynamic screen hosts a tab navigator, append the focused tab's path segment. if (screensWithOnyxTabNavigator.has(screenName)) { @@ -139,7 +152,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}` : ''}`; @@ -184,7 +197,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 c2d50ab6fe38..e3d1a0ce113d 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 {linkingConfig} from '@libs/Navigation/linkingConfig'; -import type {Route} from '@src/ROUTES'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; @@ -20,7 +19,7 @@ import getMatchingNewRoute from './getMatchingNewRoute'; * @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 = !path.startsWith('/') ? `/${path}` : path; const normalizedPathAfterRedirection = getMatchingNewRoute(normalizedPath) ?? normalizedPath; diff --git a/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts index 1903173f5a0c..873a839d95d3 100644 --- a/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts +++ b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts @@ -3,8 +3,6 @@ import type {State} from '@navigation/types'; import type {NavigationAction, NavigationState} from '@react-navigation/native'; import type {Writable} from 'type-fest'; -import type {ActionPayload} from './types'; - type MinimalAction = { action: Writable; targetState: State | undefined; @@ -30,15 +28,15 @@ function getMinimalAction(action: NavigationAction, state: NavigationState): Min currentState = currentState?.routes[currentState.index ?? -1].state; currentTargetKey = currentState?.key; - const payload = currentAction.payload as ActionPayload; + const params = 'params' in currentAction.payload && typeof currentAction.payload.params === 'object' ? currentAction.payload.params : undefined; // Creating new smaller action currentAction = { type: currentAction.type, payload: { - name: payload?.params?.screen, - params: payload?.params?.params, - path: payload?.params?.path, + name: params && 'screen' in params ? params.screen : undefined, + params: params && 'params' in params ? params.params : undefined, + path: params && 'path' in params ? params.path : undefined, }, target: currentTargetKey, }; diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index f05f5fd1553e..3a07f403f8a6 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -3,11 +3,10 @@ import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; import {getTabState} from '@libs/Navigation/helpers/tabNavigatorUtils'; import {linkingConfig} from '@libs/Navigation/linkingConfig'; -import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; import {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'; @@ -19,10 +18,12 @@ import type {NavigationContainerRef, NavigationState, PartialState} from '@react 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'; +type FullScreenRoute = Omit & Pick; + const defaultLinkToOptions: LinkToOptions = { forceReplace: false, }; @@ -33,17 +34,17 @@ const defaultLinkToOptions: LinkToOptions = { */ const ROOT_TAB_SCREENS = new Set([SCREENS.HOME, SCREENS.INBOX, SCREENS.SEARCH.ROOT, 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(currentFocusedRoute?.params ? {...currentFocusedRoute.params} : undefined, targetFocusedRoute?.params ? {...targetFocusedRoute.params} : undefined); return areNamesEqual && areParamsEqual; } -function arePathAndBackToEqual(stateFromPath: PartialState>) { +function arePathAndBackToEqual(stateFromPath: PartialState) { const focusedRouteFromPath = findFocusedRoute(stateFromPath); const params = focusedRouteFromPath?.params ?? {}; @@ -66,15 +67,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) { @@ -82,17 +90,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)); @@ -103,7 +111,7 @@ function isSwitchingTabsWithinTabNavigator(currentState: NavigationState, - matchingFullScreenRoute: NavigationPartialRoute, - lastFullScreenRoute: NavigationPartialRoute, -) { +function shouldChangeToMatchingFullScreen(newFocusedRoute: ReturnType, matchingFullScreenRoute: FullScreenRoute, lastFullScreenRoute: FullScreenRoute) { if (matchingFullScreenRoute.name !== lastFullScreenRoute.name) { return true; } @@ -138,9 +142,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; @@ -159,17 +161,16 @@ export default function linkTo(navigation: NavigationContainerRef; + const {forceReplace} = {...defaultLinkToOptions, ...options}; - const normalizedPath = normalizePath(path) as Route; - const normalizedPathAfterRedirection = (getMatchingNewRoute(normalizedPath) ?? normalizedPath) as Route; + const normalizedPath = normalizePath(path); + const normalizedPathAfterRedirection = getMatchingNewRoute(normalizedPath) ?? normalizedPath; // This is the state generated with the default getStateFromPath function. // It won't include the whole state that will be generated for this path but the focused route will be correct. // It is necessary because getActionFromState will generate RESET action for whole state generated with our custom getStateFromPath function. - const stateFromPath = getStateFromPath(normalizedPathAfterRedirection) as PartialState>; - const currentState = navigation.getRootState() as PlatformStackNavigationState; + const stateFromPath = getStateFromPath(normalizedPathAfterRedirection); + const currentState = navigation.getRootState(); const focusedRouteFromPath = findFocusedRoute(stateFromPath); const currentFocusedRoute = findFocusedRoute(currentState); @@ -192,8 +193,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) { @@ -283,11 +282,8 @@ export default function linkTo(navigation: NavigationContainerRef Promise; - log: (key: CollectionKeyBase) => void; - }; - // We intentionally do not offer an Onyx.get API because we believe it will lead to code patterns we don't want to use in this repo, but we can offer a workaround for the sake of debugging - window.Onyx.get = function (key: CollectionKeyBase) { + function get(key: TKey): Promise> { return new Promise((resolve) => { + const connectionReference: {current?: ReturnType} = {}; + let isResolved = false; // We have opted for `connectWithoutView` here as this is a debugging utility and does not relate to any view. const connection = Onyx.connectWithoutView({ key, callback: (value) => { - Onyx.disconnect(connection); + if (isResolved) { + return; + } + isResolved = true; + if (connectionReference.current) { + Onyx.disconnect(connectionReference.current); + } resolve(value); }, }); + if (isResolved) { + Onyx.disconnect(connection); + } else { + connectionReference.current = connection; + } }); - }; + } - window.Onyx.log = function (key: CollectionKeyBase) { - window.Onyx.get(key).then((value) => { + function log(key: OnyxKey): void { + get(key).then((value) => { /* eslint-disable-next-line no-console */ console.log(value); }); - }; + } + + const onyxWithDebugUtils = Object.assign(Onyx, {get, log}); + window.Onyx = onyxWithDebugUtils; 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; + } // Helper to get reportID from various sources - const getReportID = async (params: Record | undefined) => { - if (params?.reportID) { + async function getReportID(params: ReturnType): Promise { + if (params && typeof params === 'object' && 'reportID' in params && typeof params.reportID === 'string' && params.reportID) { return params.reportID; } - if (params?.transactionID) { - const transaction = await window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}` as CollectionKeyBase); - return (transaction as {reportID?: string} | undefined)?.reportID; + if (params && typeof params === 'object' && 'transactionID' in params && typeof params.transactionID === 'string' && params.transactionID) { + const transaction = await onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${params.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 onyxWithDebugUtils.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 onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`); + const parentAction = parentReportActions?.[report.parentReportActionID]; + // Debug lookups intentionally prefer the legacy message even when a modern message exists. + const parentMessage = parentAction ? getOriginalMessage({...parentAction, message: undefined}) : undefined; + if (parentMessage && 'IOUTransactionID' in parentMessage && parentMessage.IOUTransactionID) { + return parentMessage.IOUTransactionID; } } // 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 onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); + if (reportActions) { + for (const action of Object.values(reportActions)) { + const originalMessage = action ? getOriginalMessage({...action, message: undefined}) : undefined; + if (originalMessage && 'IOUTransactionID' in originalMessage && originalMessage.IOUTransactionID) { + return originalMessage.IOUTransactionID; } } } 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 onyxWithDebugUtils.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 onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`); + return parentReport?.policyID; } return undefined; - }; + } // Define lazy getters for debug data Object.defineProperties(window, { @@ -120,15 +132,15 @@ export default function addUtilsToWindow() { get: async () => { const params = getRouteParams(); - if (params?.policyID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.POLICY}${params.policyID}` as CollectionKeyBase); + if (params && typeof params === 'object' && 'policyID' in params && typeof params.policyID === 'string' && params.policyID) { + return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.POLICY}${params.policyID}`); } 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 onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); } } @@ -142,7 +154,7 @@ export default function addUtilsToWindow() { const reportID = await getReportID(params); if (reportID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}` as CollectionKeyBase); + return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); } return undefined; @@ -153,14 +165,14 @@ export default function addUtilsToWindow() { get: async () => { const params = getRouteParams(); - if (params?.transactionID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}` as CollectionKeyBase); + if (params && typeof params === 'object' && 'transactionID' in params && typeof params.transactionID === 'string' && params.transactionID) { + return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`); } - if (params?.reportID) { + if (params && typeof params === 'object' && 'reportID' in params && typeof params.reportID === 'string' && params.reportID) { const transactionID = await getTransactionIDFromReport(params.reportID); if (transactionID) { - return window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as CollectionKeyBase); + return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); } } @@ -170,7 +182,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; }, }, From 72f2ccbbb866fb06c1ee11a1188fb2badf4f7988 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:17:10 +0000 Subject: [PATCH 2/5] Reuse nested navigation path helper to avoid unused module Seatbelt-Operation: recovery-20260925-100768-knip-sign-1 --- src/libs/Navigation/helpers/getAdaptedStateFromPath.ts | 7 ++----- src/libs/Navigation/helpers/replacePathInNestedState.ts | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index f577b07b9390..ca87bc7da4cb 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -22,7 +22,6 @@ import SCREENS from '@src/SCREENS'; import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; -import {findFocusedRoute} from '@react-navigation/native'; import pick from 'lodash/pick'; import buildTabNavigatorNestedState from './buildTabNavigatorNestedState'; @@ -37,6 +36,7 @@ import getParamsFromRoute from './getParamsFromRoute'; import getStateFromPath from './getStateFromPath'; import {isFullScreenName} from './isNavigatorName'; import normalizePath from './normalizePath'; +import replacePathInNestedState from './replacePathInNestedState'; type GetAdaptedStateReturnType = ReturnType; @@ -434,10 +434,7 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR const state = getStateFromPath(normalizedPath); if (shouldReplacePathInNestedState) { - const focusedRoute = findFocusedRoute(state); - if (focusedRoute) { - focusedRoute.path = normalizedPath; - } + replacePathInNestedState(state, normalizedPath); } if (state === undefined) { diff --git a/src/libs/Navigation/helpers/replacePathInNestedState.ts b/src/libs/Navigation/helpers/replacePathInNestedState.ts index 01c115ddb1b4..e3acbbe259cd 100644 --- a/src/libs/Navigation/helpers/replacePathInNestedState.ts +++ b/src/libs/Navigation/helpers/replacePathInNestedState.ts @@ -1,10 +1,8 @@ -import type {RootNavigatorParamList} from '@libs/Navigation/types'; - import type {NavigationState, PartialState} from '@react-navigation/native'; import {findFocusedRoute} from '@react-navigation/native'; -function replacePathInNestedState(state: PartialState>, path: string) { +function replacePathInNestedState(state: PartialState, path: string) { const found = findFocusedRoute(state); if (!found) { return; From f8f3f3cddab33423997f6a87139fc2f21a7d3184 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:39:10 +0000 Subject: [PATCH 3/5] Address reviewer findings for Expensify/App#100768 Seatbelt-Operation: 2039c3a5-1f02-40d5-aa18-aea6dce4e324 --- .../Navigation/helpers/getPathFromState.ts | 4 +- src/libs/Navigation/helpers/linkTo/index.ts | 9 +- src/setup/addUtilsToWindow.ts | 69 ++- src/types/modules/react-native-onyx.d.ts | 3 +- tests/navigation/getPathFromStateTests.ts | 78 +++ tests/unit/addUtilsToWindowTest.ts | 463 ++++++++++++++++++ 6 files changed, 584 insertions(+), 42 deletions(-) create mode 100644 tests/unit/addUtilsToWindowTest.ts diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index c4426c17eeb0..403d8bc59204 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -1,7 +1,7 @@ import Log from '@libs/Log'; import {config, normalizedConfigs, screensWithOnyxTabNavigator} from '@libs/Navigation/linkingConfig/config'; import type {State} from '@libs/Navigation/types'; -import {hasKey} from '@libs/ObjectUtils'; +import {hasKey, isRecord} from '@libs/ObjectUtils'; import type {NavigationState, PartialState} from '@react-navigation/native'; @@ -146,7 +146,7 @@ function getPathFromStateWithDynamicRoute(state: State): string { return RNGetPathFromState(state, config); } - let actualSuffix = buildSuffixFromPattern(suffixPattern, focusedRoute?.params ? {...focusedRoute.params} : 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)) { diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index 01e8881e3dfc..dad5ba8614db 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -58,7 +58,10 @@ function areNamesAndParamsEqual(currentState: NavigationState, stateFromPath: Pa const targetFocusedRoute = findFocusedRoute(stateFromPath); const areNamesEqual = currentFocusedRoute?.name === targetFocusedRoute?.name; - const areParamsEqual = shallowCompare(currentFocusedRoute?.params ? {...currentFocusedRoute.params} : undefined, targetFocusedRoute?.params ? {...targetFocusedRoute.params} : undefined); + const areParamsEqual = shallowCompare( + isRecord(currentFocusedRoute?.params) ? currentFocusedRoute.params : undefined, + isRecord(targetFocusedRoute?.params) ? targetFocusedRoute.params : undefined, + ); return areNamesEqual && areParamsEqual; } @@ -239,7 +242,7 @@ export default function linkTo(navigation: NavigationContainerRef(key: TKey): Promise> { return new Promise((resolve) => { - const connectionReference: {current?: ReturnType} = {}; - let isResolved = false; // We have opted for `connectWithoutView` here as this is a debugging utility and does not relate to any view. const connection = Onyx.connectWithoutView({ key, callback: (value) => { - if (isResolved) { - return; - } - isResolved = true; - if (connectionReference.current) { - Onyx.disconnect(connectionReference.current); - } + Onyx.disconnect(connection); resolve(value); }, }); - if (isResolved) { - Onyx.disconnect(connection); - } else { - connectionReference.current = connection; - } }); } @@ -68,13 +56,23 @@ export default function addUtilsToWindow() { return navigationRef.current?.getCurrentRoute()?.params; } + function getStringRouteParam(params: ReturnType, key: 'reportID' | 'transactionID' | 'policyID'): string | undefined { + if (!isRecord(params) || !hasKey(params, key)) { + return undefined; + } + const value = params[key]; + return typeof value === 'string' && value ? value : undefined; + } + // Helper to get reportID from various sources async function getReportID(params: ReturnType): Promise { - if (params && typeof params === 'object' && 'reportID' in params && typeof params.reportID === 'string' && params.reportID) { - return params.reportID; + const reportID = getStringRouteParam(params, 'reportID'); + if (reportID) { + return reportID; } - if (params && typeof params === 'object' && 'transactionID' in params && typeof params.transactionID === 'string' && params.transactionID) { - const transaction = await onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`); + const transactionID = getStringRouteParam(params, 'transactionID'); + if (transactionID) { + const transaction = await onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); return transaction?.reportID; } return undefined; @@ -87,22 +85,18 @@ export default function addUtilsToWindow() { // First try: Get from parent report action (for transaction thread reports) if (report?.parentReportID && report?.parentReportActionID) { const parentReportActions = await onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`); - const parentAction = parentReportActions?.[report.parentReportActionID]; - // Debug lookups intentionally prefer the legacy message even when a modern message exists. - const parentMessage = parentAction ? getOriginalMessage({...parentAction, message: undefined}) : undefined; - if (parentMessage && 'IOUTransactionID' in parentMessage && parentMessage.IOUTransactionID) { - return parentMessage.IOUTransactionID; + 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 onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); - if (reportActions) { - for (const action of Object.values(reportActions)) { - const originalMessage = action ? getOriginalMessage({...action, message: undefined}) : undefined; - if (originalMessage && 'IOUTransactionID' in originalMessage && originalMessage.IOUTransactionID) { - return originalMessage.IOUTransactionID; - } + for (const action of Object.values(reportActions ?? {})) { + const transactionID = getLinkedTransactionID(action); + if (transactionID) { + return transactionID; } } @@ -132,8 +126,9 @@ export default function addUtilsToWindow() { get: async () => { const params = getRouteParams(); - if (params && typeof params === 'object' && 'policyID' in params && typeof params.policyID === 'string' && params.policyID) { - return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.POLICY}${params.policyID}`); + const routePolicyID = getStringRouteParam(params, 'policyID'); + if (routePolicyID) { + return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.POLICY}${routePolicyID}`); } const reportID = await getReportID(params); @@ -165,12 +160,14 @@ export default function addUtilsToWindow() { get: async () => { const params = getRouteParams(); - if (params && typeof params === 'object' && 'transactionID' in params && typeof params.transactionID === 'string' && params.transactionID) { - return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`); + const routeTransactionID = getStringRouteParam(params, 'transactionID'); + if (routeTransactionID) { + return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${routeTransactionID}`); } - if (params && typeof params === 'object' && 'reportID' in params && typeof params.reportID === 'string' && params.reportID) { - const transactionID = await getTransactionIDFromReport(params.reportID); + const reportID = getStringRouteParam(params, 'reportID'); + if (reportID) { + const transactionID = await getTransactionIDFromReport(reportID); if (transactionID) { return onyxWithDebugUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); } diff --git a/src/types/modules/react-native-onyx.d.ts b/src/types/modules/react-native-onyx.d.ts index 73615460afd9..37db3ce6aab4 100644 --- a/src/types/modules/react-native-onyx.d.ts +++ b/src/types/modules/react-native-onyx.d.ts @@ -1,6 +1,7 @@ import type {OnyxCollectionKey, OnyxDerivedKey, OnyxFormDraftKey, OnyxFormKey, OnyxValueKey, OnyxValues} from '@src/ONYXKEYS'; import type Onyx from 'react-native-onyx'; +import type {OnyxKey, OnyxValue} from 'react-native-onyx'; import type {CollectionKeyBase} from 'react-native-onyx/dist/types'; declare module 'react-native-onyx' { @@ -16,7 +17,7 @@ declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { Onyx: typeof Onyx & { - get: (key: CollectionKeyBase) => Promise; + get: (key: TKey) => Promise>; 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..444ed77d2222 --- /dev/null +++ b/tests/unit/addUtilsToWindowTest.ts @@ -0,0 +1,463 @@ +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('delivers a member after connect returns and disconnects that exact handle before resolving', async () => { + // Given a stored member and the real deferred Onyx subscription, the one-shot read must release its handle. + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey; + await Onyx.set(key, modernTransaction); + await waitForBatchedUpdates(); + const connect = jest.spyOn(Onyx, 'connectWithoutView'); + const disconnect = jest.spyOn(Onyx, 'disconnect'); + + // When a debug read starts, connect has returned but the deferred callback has not fired. + const result = window.Onyx.get(key) satisfies Promise>; + expect(connect).toHaveBeenCalledTimes(1); + expect(disconnect).not.toHaveBeenCalled(); + const value = await result; + + // Then resolution follows one disconnect of the returned handle and later writes have no live subscription. + expect(value).toEqual(modernTransaction); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveReturnedWith(disconnect.mock.calls.at(0)?.[0]); + await Onyx.merge(key, {merchant: 'Updated'}); + await waitForBatchedUpdates(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it.each([true, false])('reads a regular key with deferred one-shot delivery, 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(); + const connect = jest.spyOn(Onyx, 'connectWithoutView'); + const disconnect = jest.spyOn(Onyx, 'disconnect'); + + // When the public read starts, its real connection returns before the deferred initial delivery. + const result = window.Onyx.get(ONYXKEYS.SESSION) satisfies Promise>; + expect(connect).toHaveBeenCalledTimes(1); + expect(disconnect).not.toHaveBeenCalled(); + const session = await result; + + // Then it resolves only after releasing that handle, and subsequent writes cannot deliver again. + expect(session).toEqual(isPresent ? storedSession : undefined); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveReturnedWith(disconnect.mock.calls.at(0)?.[0]); + await Onyx.set(ONYXKEYS.SESSION, {accountID: 456}); + await waitForBatchedUpdates(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it('preserves absent member delivery and releases its exact connection before resolving', async () => { + // Given a missing member, its value is absent without losing the transaction return type. + const connect = jest.spyOn(Onyx, 'connectWithoutView'); + const disconnect = jest.spyOn(Onyx, 'disconnect'); + + // When the public read starts, its initial delivery must still follow connect returning. + const result = window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}missing`) satisfies Promise>; + expect(connect).toHaveBeenCalledTimes(1); + expect(disconnect).not.toHaveBeenCalled(); + const missing = await result; + + // Then absence resolves after one exact-handle disconnect, and creating the member cannot deliver again. + expect(missing).toBeUndefined(); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveReturnedWith(disconnect.mock.calls.at(0)?.[0]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}missing`, modernTransaction); + await waitForBatchedUpdates(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + 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. + const connect = jest.spyOn(Onyx, 'connectWithoutView'); + const disconnect = jest.spyOn(Onyx, 'disconnect'); + + // When each public root read starts, it returns a typed collection promise before initial delivery. + const initialResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; + expect(connect).toHaveBeenCalledTimes(1); + expect(disconnect).not.toHaveBeenCalled(); + const initial = await initialResult; + expect(initial).toEqual({}); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveNthReturnedWith(1, disconnect.mock.calls.at(0)?.[0]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, modernTransaction); + await waitForBatchedUpdates(); + expect(disconnect).toHaveBeenCalledTimes(1); + + const populatedResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; + expect(connect).toHaveBeenCalledTimes(2); + expect(disconnect).toHaveBeenCalledTimes(1); + const populated = await populatedResult; + expect(populated).toEqual({['transactions_modern' satisfies OnyxKey]: modernTransaction}); + expect(disconnect).toHaveBeenCalledTimes(2); + expect(connect).toHaveNthReturnedWith(2, disconnect.mock.calls.at(1)?.[0]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, null); + await waitForBatchedUpdates(); + expect(disconnect).toHaveBeenCalledTimes(2); + + const emptyResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; + expect(connect).toHaveBeenCalledTimes(3); + expect(disconnect).toHaveBeenCalledTimes(2); + const empty = await emptyResult; + + // Then snapshots preserve their collection category, and every read releases its own handle exactly once. + expect(empty).toEqual({}); + expect(disconnect).toHaveBeenCalledTimes(3); + expect(connect).toHaveNthReturnedWith(3, disconnect.mock.calls.at(2)?.[0]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, modernTransaction); + await waitForBatchedUpdates(); + expect(disconnect).toHaveBeenCalledTimes(3); + }); + + 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({ + ['report_report' satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', parentReportActionID: 'parent-action'} satisfies Report, + ['reportActions_parent' satisfies OnyxKey]: {[parentAction.reportActionID]: parentAction}, + ['reportActions_report' satisfies OnyxKey]: { + fallback: {...iouAction, reportActionID: 'fallback', message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, text: '', IOUTransactionID: 'fallback'}}, + }, + ['transactions_modern' satisfies OnyxKey]: modernTransaction, + ['transactions_legacy' satisfies OnyxKey]: legacyTransaction, + ['transactions_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({ + ['report_report' satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', parentReportActionID: 'absent'} satisfies Report, + ['reportActions_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'}}, + }, + ['transactions_modern' satisfies OnyxKey]: modernTransaction, + ['transactions_legacy' satisfies OnyxKey]: legacyTransaction, + ['transactions_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({ + ['report_direct-report' satisfies OnyxKey]: report, + ['report_report' satisfies OnyxKey]: {reportID: 'report', policyID: 'other-policy'}, + ['report_parent' satisfies OnyxKey]: {reportID: 'parent', policyID: 'parent-policy'}, + ['transactions_modern' satisfies OnyxKey]: modernTransaction, + ['policy_direct-policy' satisfies OnyxKey]: policy, + ['policy_report-policy' satisfies OnyxKey]: {id: 'report-policy'}, + ['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({ + ['transactions_modern' satisfies OnyxKey]: modernTransaction, + ['report_report' satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', ...(hasOwnPolicy ? {policyID: 'own-policy'} : {})}, + ['report_parent' satisfies OnyxKey]: {reportID: 'parent', policyID: 'parent-policy'}, + ['policy_own-policy' satisfies OnyxKey]: ownPolicy, + ['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({ + ['report_123' satisfies OnyxKey]: {reportID: '123'}, + ['transactions_123' satisfies OnyxKey]: modernTransaction, + ['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]); + }); +}); From c7072cb9fb2a3b5eaa41d852e9d5c58f81101d38 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:26:32 +0000 Subject: [PATCH 4/5] Simplify debug helpers and assertions for Expensify/App#100768 Seatbelt-Operation: 981bc10d-de59-405e-a1ed-f597bf83c62e --- src/setup/addUtilsToWindow.ts | 7 +- tests/unit/addUtilsToWindowTest.ts | 132 ++++++++++++++--------------- 2 files changed, 68 insertions(+), 71 deletions(-) diff --git a/src/setup/addUtilsToWindow.ts b/src/setup/addUtilsToWindow.ts index cf96f353a00b..976cc33aabe2 100644 --- a/src/setup/addUtilsToWindow.ts +++ b/src/setup/addUtilsToWindow.ts @@ -1,6 +1,6 @@ import {isProduction as isProductionLib} from '@libs/Environment/Environment'; import navigationRef from '@libs/Navigation/navigationRef'; -import {hasKey, isRecord} from '@libs/ObjectUtils'; +import {isRecord} from '@libs/ObjectUtils'; import {getLinkedTransactionID} from '@libs/ReportActionsUtils'; import {setSupportAuthToken} from '@userActions/Session'; @@ -57,10 +57,11 @@ export default function addUtilsToWindow() { } function getStringRouteParam(params: ReturnType, key: 'reportID' | 'transactionID' | 'policyID'): string | undefined { - if (!isRecord(params) || !hasKey(params, key)) { + if (!isRecord(params)) { return undefined; } - const value = params[key]; + const recordParams: Record = params; + const value = recordParams[key]; return typeof value === 'string' && value ? value : undefined; } diff --git a/tests/unit/addUtilsToWindowTest.ts b/tests/unit/addUtilsToWindowTest.ts index 444ed77d2222..83011f376139 100644 --- a/tests/unit/addUtilsToWindowTest.ts +++ b/tests/unit/addUtilsToWindowTest.ts @@ -10,7 +10,7 @@ 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 type {OnyxCollection, OnyxEntry, OnyxKey, OnyxValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -57,6 +57,24 @@ const modernTransaction = { const legacyTransaction = {...modernTransaction, transactionID: 'legacy'} satisfies Transaction; const fallbackTransaction = {...modernTransaction, transactionID: 'fallback'} satisfies Transaction; +async function readDebugOnyxOnce(key: TKey): Promise> { + const connect = jest.spyOn(Onyx, 'connectWithoutView'); + const disconnect = jest.spyOn(Onyx, 'disconnect'); + const connectsBefore = connect.mock.calls.length; + const disconnectsBefore = disconnect.mock.calls.length; + const result = window.Onyx.get(key) satisfies Promise>; + expect(connect).toHaveBeenCalledTimes(connectsBefore + 1); + expect(disconnect).toHaveBeenCalledTimes(disconnectsBefore); + const value = await result; + expect(disconnect).toHaveBeenCalledTimes(disconnectsBefore + 1); + const connection = connect.mock.results.at(connectsBefore); + expect(connection?.type).toBe('return'); + expect(connection?.value).toBeDefined(); + expect(disconnect.mock.calls.at(disconnectsBefore)).toHaveLength(1); + expect(disconnect.mock.calls.at(disconnectsBefore)?.[0]).toBe(connection?.value); + return value; +} + describe('addUtilsToWindow', () => { const windowDescriptors = new Map(); const onyxDescriptors = new Map(); @@ -115,22 +133,18 @@ describe('addUtilsToWindow', () => { const key = `${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey; await Onyx.set(key, modernTransaction); await waitForBatchedUpdates(); - const connect = jest.spyOn(Onyx, 'connectWithoutView'); const disconnect = jest.spyOn(Onyx, 'disconnect'); // When a debug read starts, connect has returned but the deferred callback has not fired. - const result = window.Onyx.get(key) satisfies Promise>; - expect(connect).toHaveBeenCalledTimes(1); - expect(disconnect).not.toHaveBeenCalled(); + const result = readDebugOnyxOnce(key) satisfies Promise>; const value = await result; + const valueDisconnects = disconnect.mock.calls.length; // Then resolution follows one disconnect of the returned handle and later writes have no live subscription. expect(value).toEqual(modernTransaction); - expect(disconnect).toHaveBeenCalledTimes(1); - expect(connect).toHaveReturnedWith(disconnect.mock.calls.at(0)?.[0]); await Onyx.merge(key, {merchant: 'Updated'}); await waitForBatchedUpdates(); - expect(disconnect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(valueDisconnects); }); it.each([true, false])('reads a regular key with deferred one-shot delivery, present: %s', async (isPresent) => { @@ -140,85 +154,67 @@ describe('addUtilsToWindow', () => { await Onyx.set(ONYXKEYS.SESSION, storedSession); } await waitForBatchedUpdates(); - const connect = jest.spyOn(Onyx, 'connectWithoutView'); const disconnect = jest.spyOn(Onyx, 'disconnect'); // When the public read starts, its real connection returns before the deferred initial delivery. - const result = window.Onyx.get(ONYXKEYS.SESSION) satisfies Promise>; - expect(connect).toHaveBeenCalledTimes(1); - expect(disconnect).not.toHaveBeenCalled(); + const result = readDebugOnyxOnce(ONYXKEYS.SESSION) satisfies Promise>; const session = await result; + const sessionDisconnects = disconnect.mock.calls.length; // Then it resolves only after releasing that handle, and subsequent writes cannot deliver again. expect(session).toEqual(isPresent ? storedSession : undefined); - expect(disconnect).toHaveBeenCalledTimes(1); - expect(connect).toHaveReturnedWith(disconnect.mock.calls.at(0)?.[0]); await Onyx.set(ONYXKEYS.SESSION, {accountID: 456}); await waitForBatchedUpdates(); - expect(disconnect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(sessionDisconnects); }); it('preserves absent member delivery and releases its exact connection before resolving', async () => { // Given a missing member, its value is absent without losing the transaction return type. - const connect = jest.spyOn(Onyx, 'connectWithoutView'); const disconnect = jest.spyOn(Onyx, 'disconnect'); // When the public read starts, its initial delivery must still follow connect returning. - const result = window.Onyx.get(`${ONYXKEYS.COLLECTION.TRANSACTION}missing`) satisfies Promise>; - expect(connect).toHaveBeenCalledTimes(1); - expect(disconnect).not.toHaveBeenCalled(); + const result = readDebugOnyxOnce(`${ONYXKEYS.COLLECTION.TRANSACTION}missing`) satisfies Promise>; const missing = await result; + const missingDisconnects = disconnect.mock.calls.length; // Then absence resolves after one exact-handle disconnect, and creating the member cannot deliver again. expect(missing).toBeUndefined(); - expect(disconnect).toHaveBeenCalledTimes(1); - expect(connect).toHaveReturnedWith(disconnect.mock.calls.at(0)?.[0]); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}missing`, modernTransaction); await waitForBatchedUpdates(); - expect(disconnect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(missingDisconnects); }); 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. - const connect = jest.spyOn(Onyx, 'connectWithoutView'); const disconnect = jest.spyOn(Onyx, 'disconnect'); // When each public root read starts, it returns a typed collection promise before initial delivery. - const initialResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; - expect(connect).toHaveBeenCalledTimes(1); - expect(disconnect).not.toHaveBeenCalled(); + const initialResult = readDebugOnyxOnce(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; const initial = await initialResult; + const initialDisconnects = disconnect.mock.calls.length; expect(initial).toEqual({}); - expect(disconnect).toHaveBeenCalledTimes(1); - expect(connect).toHaveNthReturnedWith(1, disconnect.mock.calls.at(0)?.[0]); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, modernTransaction); await waitForBatchedUpdates(); - expect(disconnect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(initialDisconnects); - const populatedResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; - expect(connect).toHaveBeenCalledTimes(2); - expect(disconnect).toHaveBeenCalledTimes(1); + const populatedResult = readDebugOnyxOnce(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; const populated = await populatedResult; - expect(populated).toEqual({['transactions_modern' satisfies OnyxKey]: modernTransaction}); - expect(disconnect).toHaveBeenCalledTimes(2); - expect(connect).toHaveNthReturnedWith(2, disconnect.mock.calls.at(1)?.[0]); + const populatedDisconnects = disconnect.mock.calls.length; + expect(populated).toEqual({[`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction}); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, null); await waitForBatchedUpdates(); - expect(disconnect).toHaveBeenCalledTimes(2); + expect(disconnect).toHaveBeenCalledTimes(populatedDisconnects); - const emptyResult = window.Onyx.get(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; - expect(connect).toHaveBeenCalledTimes(3); - expect(disconnect).toHaveBeenCalledTimes(2); + const emptyResult = readDebugOnyxOnce(ONYXKEYS.COLLECTION.TRANSACTION) satisfies Promise>; const empty = await emptyResult; + const emptyDisconnects = disconnect.mock.calls.length; // Then snapshots preserve their collection category, and every read releases its own handle exactly once. expect(empty).toEqual({}); - expect(disconnect).toHaveBeenCalledTimes(3); - expect(connect).toHaveNthReturnedWith(3, disconnect.mock.calls.at(2)?.[0]); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}modern`, modernTransaction); await waitForBatchedUpdates(); - expect(disconnect).toHaveBeenCalledTimes(3); + expect(disconnect).toHaveBeenCalledTimes(emptyDisconnects); }); it.each([ @@ -277,14 +273,14 @@ describe('addUtilsToWindow', () => { // 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({ - ['report_report' satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', parentReportActionID: 'parent-action'} satisfies Report, - ['reportActions_parent' satisfies OnyxKey]: {[parentAction.reportActionID]: parentAction}, - ['reportActions_report' satisfies OnyxKey]: { + [`${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'}}, }, - ['transactions_modern' satisfies OnyxKey]: modernTransaction, - ['transactions_legacy' satisfies OnyxKey]: legacyTransaction, - ['transactions_fallback' satisfies OnyxKey]: fallbackTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}legacy` satisfies OnyxKey]: legacyTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}fallback` satisfies OnyxKey]: fallbackTransaction, }); await waitForBatchedUpdates(); @@ -309,17 +305,17 @@ describe('addUtilsToWindow', () => { } satisfies ReportAction; mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params: {reportID: 'report'}}); await Onyx.multiSet({ - ['report_report' satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', parentReportActionID: 'absent'} satisfies Report, - ['reportActions_report' satisfies OnyxKey]: { + [`${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'}}, }, - ['transactions_modern' satisfies OnyxKey]: modernTransaction, - ['transactions_legacy' satisfies OnyxKey]: legacyTransaction, - ['transactions_fallback' satisfies OnyxKey]: fallbackTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}modern` satisfies OnyxKey]: modernTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}legacy` satisfies OnyxKey]: legacyTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}fallback` satisfies OnyxKey]: fallbackTransaction, }); await waitForBatchedUpdates(); @@ -355,13 +351,13 @@ describe('addUtilsToWindow', () => { const policy = {id: 'direct-policy', name: 'Direct'} satisfies Partial; mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.REPORT, params}); await Onyx.multiSet({ - ['report_direct-report' satisfies OnyxKey]: report, - ['report_report' satisfies OnyxKey]: {reportID: 'report', policyID: 'other-policy'}, - ['report_parent' satisfies OnyxKey]: {reportID: 'parent', policyID: 'parent-policy'}, - ['transactions_modern' satisfies OnyxKey]: modernTransaction, - ['policy_direct-policy' satisfies OnyxKey]: policy, - ['policy_report-policy' satisfies OnyxKey]: {id: 'report-policy'}, - ['policy_parent-policy' satisfies OnyxKey]: {id: 'parent-policy'}, + [`${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(); @@ -378,11 +374,11 @@ describe('addUtilsToWindow', () => { const parentPolicy = {id: 'parent-policy'} satisfies Partial; mockGetCurrentRoute.mockReturnValue({key: 'route', name: SCREENS.DEBUG.TRANSACTION, params: {transactionID: 'modern'}}); await Onyx.multiSet({ - ['transactions_modern' satisfies OnyxKey]: modernTransaction, - ['report_report' satisfies OnyxKey]: {reportID: 'report', parentReportID: 'parent', ...(hasOwnPolicy ? {policyID: 'own-policy'} : {})}, - ['report_parent' satisfies OnyxKey]: {reportID: 'parent', policyID: 'parent-policy'}, - ['policy_own-policy' satisfies OnyxKey]: ownPolicy, - ['policy_parent-policy' satisfies OnyxKey]: parentPolicy, + [`${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(); @@ -430,9 +426,9 @@ describe('addUtilsToWindow', () => { 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({ - ['report_123' satisfies OnyxKey]: {reportID: '123'}, - ['transactions_123' satisfies OnyxKey]: modernTransaction, - ['policy_123' satisfies OnyxKey]: {id: '123'}, + [`${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', From 07112588ab248daf61e9e71f4426343a6139e63b Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:18:04 +0000 Subject: [PATCH 5/5] Narrow debug route params from unknown for Expensify/App#100768 Seatbelt-Operation: ea2c4845-1b6f-4086-a320-6f4997a308b7 --- src/setup/addUtilsToWindow.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/setup/addUtilsToWindow.ts b/src/setup/addUtilsToWindow.ts index 976cc33aabe2..1f11204166a9 100644 --- a/src/setup/addUtilsToWindow.ts +++ b/src/setup/addUtilsToWindow.ts @@ -56,12 +56,11 @@ export default function addUtilsToWindow() { return navigationRef.current?.getCurrentRoute()?.params; } - function getStringRouteParam(params: ReturnType, key: 'reportID' | 'transactionID' | 'policyID'): string | undefined { + function getStringRouteParam(params: unknown, key: 'reportID' | 'transactionID' | 'policyID'): string | undefined { if (!isRecord(params)) { return undefined; } - const recordParams: Record = params; - const value = recordParams[key]; + const value = params[key]; return typeof value === 'string' && value ? value : undefined; }