diff --git a/src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx b/src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx
index debc39c40f38..da638d5b673e 100644
--- a/src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx
+++ b/src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx
@@ -17,7 +17,6 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Card} from '@src/types/onyx';
-import type {CompanyCardFeed} from '@src/types/onyx/CardFeeds';
import React, {useEffect, useEffectEvent} from 'react';
@@ -94,8 +93,8 @@ function FixCompanyCardConnection({card, policyID, policyName}: FixCompanyCardCo
return ;
}
- const customFeedName = cardFeeds?.settings?.companyCardNicknames?.[card.bank as CompanyCardFeed];
- const feedName = getCustomOrFormattedFeedName(translate, card.bank as CompanyCardFeed, customFeedName, false) ?? '';
+ const customFeedName = cardFeeds?.settings?.companyCardNicknames?.[card.bank];
+ const feedName = getCustomOrFormattedFeedName(translate, card.bank, customFeedName, false) ?? '';
const subtitle = policyName
? translate('homePage.timeSensitiveSection.fixCompanyCardConnection.subtitle', {policyName})
: translate('homePage.timeSensitiveSection.fixCompanyCardConnection.defaultSubtitle');
diff --git a/src/pages/inbox/ReportHeader.tsx b/src/pages/inbox/ReportHeader.tsx
index 6d72ee5e5eb1..5134a48673e6 100644
--- a/src/pages/inbox/ReportHeader.tsx
+++ b/src/pages/inbox/ReportHeader.tsx
@@ -15,21 +15,31 @@ import {getReportName} from '@libs/ReportNameUtils';
import {getReportOfflinePendingActionAndErrors, isInvoiceReport, isMoneyRequestReport, isReportTransactionThread} from '@libs/ReportUtils';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Route} from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import {useRoute} from '@react-navigation/native';
import React from 'react';
+import type ReportScreenNavigationProps from './types';
+
import HeaderView from './HeaderView';
+// navigateToLinkedReportAction emits a SearchReport marker that the registered backTo declaration omits.
+type ReportHeaderRoute = Omit & {
+ readonly params?: Readonly<
+ Partial> & {
+ backTo?: ReportScreenNavigationProps['route']['params']['backTo'] | typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT;
+ }
+ >;
+};
+
/**
* Owns header variant selection, back button logic, and OfflineWithFeedback wrapper.
* Subscribes to report type internally — ReportScreen passes nothing.
*/
function ReportHeader() {
- const route = useRoute();
- const routeParams = route.params as {reportID?: string; backTo?: string} | undefined;
+ const route = useRoute();
+ const routeParams = route.params;
const reportIDFromRoute = getNonEmptyStringOnyxID(routeParams?.reportID);
const backTo = routeParams?.backTo;
@@ -58,7 +68,7 @@ function ReportHeader() {
return;
}
if (prioritizeBackTo && backTo) {
- Navigation.goBack(backTo as Route, options);
+ Navigation.goBack(backTo, options);
return;
}
if (isInNarrowPaneModal) {
@@ -66,7 +76,7 @@ function ReportHeader() {
return;
}
if (backTo) {
- Navigation.goBack(backTo as Route, options);
+ Navigation.goBack(backTo, options);
return;
}
Navigation.goBack(undefined, options);
diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx
index 73ae231973c4..9e8c2ccfd662 100644
--- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx
+++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx
@@ -383,10 +383,10 @@ function ActionContentRouter({
/>
);
}
- if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION) {
+ if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) {
return (
}
+ action={action}
originalReport={originalReport}
/>
);
@@ -420,10 +420,10 @@ function ActionContentRouter({
);
}
- if (action.actionName === CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION) {
+ if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) {
return (
}
+ action={action}
originalReport={originalReport}
/>
);
diff --git a/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts b/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts
index c9f9e89c0363..2cd7bdf60803 100644
--- a/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts
+++ b/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts
@@ -39,8 +39,7 @@ function useScanActions() {
// useState lazy initializer generates the ID once on mount and keeps it stable across renders
const [reportID] = useState(() => generateReportID());
- const policyChatForActivePolicy: OnyxTypes.Report =
- !isEmptyObject(activePolicy) && isGroupPolicy(activePolicy) && policyChats.length > 0 ? (policyChats.at(0) ?? ({} as OnyxTypes.Report)) : ({} as OnyxTypes.Report);
+ const policyChatForActivePolicy = !isEmptyObject(activePolicy) && isGroupPolicy(activePolicy) && policyChats.length > 0 ? policyChats.at(0) : undefined;
const startScan = () => {
interceptAnonymousUser(() => {
diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts
index b274c5509122..ab03cf29bad6 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts
+++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts
@@ -1,16 +1,16 @@
import type {ScanRoute} from '@pages/iou/request/step/IOURequestStepScan/types';
-import {useNavigation, useRoute} from '@react-navigation/native';
+import CONST from '@src/CONST';
-type ScanRouteParams = ScanRoute['params'];
+import {useNavigation, useRoute} from '@react-navigation/native';
-type ScanRouteInfo = ScanRouteParams & {
+type ScanRouteInfo = Partial> & {
/** The route name — used to determine if we're in the create-scan flow vs edit/replace */
routeName: string;
};
/**
- * Returns typed route params and route name for the scan screen.
+ * Returns the consumed IOU type and route name for the scan screen.
* When rendered as a standalone screen (STEP_SCAN), params come from useRoute() directly.
* When rendered inside a TopTab (CREATE flow), params come from the parent navigator's current route.
*/
@@ -19,12 +19,16 @@ function useScanRouteParams(): ScanRouteInfo {
const navigation = useNavigation();
if (route.params) {
- return {...(route.params as ScanRouteParams), routeName: route.name};
+ const params = route.params;
+ const iouType = 'iouType' in params ? Object.values(CONST.IOU.TYPE).find((value) => value === params.iouType) : undefined;
+ return {iouType, routeName: route.name};
}
const parentState = navigation.getParent()?.getState();
const parentRoute = parentState?.routes[parentState?.index ?? 0];
- return {...(parentRoute?.params as ScanRouteParams), routeName: parentRoute?.name ?? ''};
+ const params = parentRoute?.params;
+ const iouType = params && 'iouType' in params ? Object.values(CONST.IOU.TYPE).find((value) => value === params.iouType) : undefined;
+ return {iouType, routeName: parentRoute?.name ?? ''};
}
export default useScanRouteParams;
diff --git a/tests/unit/hooks/useScanRouteParamsTest.tsx b/tests/unit/hooks/useScanRouteParamsTest.tsx
new file mode 100644
index 000000000000..1069f55db8bb
--- /dev/null
+++ b/tests/unit/hooks/useScanRouteParamsTest.tsx
@@ -0,0 +1,188 @@
+import {renderHook} from '@testing-library/react-native';
+
+import useScanRouteParams from '@pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams';
+import type {ScanRoute} from '@pages/iou/request/step/IOURequestStepScan/types';
+
+import CONST from '@src/CONST';
+import SCREENS from '@src/SCREENS';
+
+import type {NavigationProp, NavigationState, PartialState} from '@react-navigation/native';
+
+import {useNavigation, useRoute} from '@react-navigation/native';
+
+import createMock from '../../utils/createMock';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: jest.fn, Parameters>(),
+ useRoute: jest.fn, Parameters>(),
+}));
+
+const mockUseNavigation = jest.mocked(useNavigation);
+const mockUseRoute = jest.mocked(useRoute);
+const scanNavigation = createMock>({
+ // A spy supplies the parent while retaining getParent's generic call contract on the navigation prop.
+ getParent: () => {
+ throw new Error('Scan parent navigation mock must be configured');
+ },
+});
+const mockGetParent = jest.spyOn(scanNavigation, 'getParent');
+// The parent hook boundary can expose an absent or partially rehydrated state. Do not claim a complete NavigationState.
+const mockGetState = jest.fn | undefined, Parameters['getState']>>();
+const scanIOUTypes = Object.values(CONST.IOU.TYPE) satisfies Array;
+
+const invalidScanParams = [
+ {description: 'a missing IOU type', params: {}},
+ {description: 'an undefined IOU type', params: {iouType: undefined}},
+ {description: 'an empty IOU type', params: {iouType: ''}},
+ {description: 'an unrecognized IOU type', params: {iouType: 'refund'}},
+ {description: 'a numeric IOU type', params: {iouType: 1}},
+ {description: 'a null IOU type', params: {iouType: null}},
+ {description: 'a boolean IOU type', params: {iouType: false}},
+ {description: 'an object IOU type', params: {iouType: {type: CONST.IOU.TYPE.CREATE}}},
+ {description: 'an array IOU type', params: {iouType: [CONST.IOU.TYPE.SPLIT]}},
+];
+
+describe('useScanRouteParams', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetState.mockReset();
+ mockUseNavigation.mockReturnValue(scanNavigation);
+ mockUseRoute.mockReturnValue({key: 'scan-tab', name: CONST.IOU.REQUEST_TYPE.SCAN});
+ // getParent is a generic navigation boundary. Only the getState method used by this hook is mocked.
+ mockGetParent.mockReturnValue({getState: mockGetState});
+ });
+
+ it.each(scanIOUTypes)('preserves direct %s params ahead of a conflicting parent', (iouType) => {
+ // Given a standalone scan route and a different parent, direct params must win without leaking other fields.
+ const params = {iouType, transactionID: '123', reportID: '456'} satisfies Partial;
+ mockUseRoute.mockReturnValue(createMock({name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params}));
+ mockGetState.mockReturnValue({
+ index: 0,
+ routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.SPLIT}}],
+ });
+
+ // When the real hook consumes the navigation hooks, it must preserve every enum member, including split-expense.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then the complete view contains only the direct IOU type and route name.
+ expect(result.current).toStrictEqual({iouType, routeName: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN});
+ expect(mockGetParent).not.toHaveBeenCalled();
+ });
+
+ it.each([{}, {transactionID: '123'} satisfies Partial])('keeps direct params priority when iouType is absent: %j', (params) => {
+ // Given truthy direct params without iouType, the parent's valid value must not fill the gap.
+ mockUseRoute.mockReturnValue({key: 'direct-scan', name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params});
+ mockGetState.mockReturnValue({index: 0, routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.CREATE}}]});
+
+ // When a direct route is present, even an empty params object selects that route.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then the undefined type and direct name are explicit, and no extra params survive.
+ expect(result.current).toStrictEqual({iouType: undefined, routeName: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN});
+ expect(mockGetParent).not.toHaveBeenCalled();
+ });
+
+ it.each(scanIOUTypes)('preserves parent %s params at a nonzero selected index', (iouType) => {
+ // Given a scan tab without params, the selected parent route must win over the first route.
+ const params = {iouType, transactionID: '789', reportID: '456'} satisfies Partial;
+ mockGetState.mockReturnValue({
+ index: 1,
+ routes: [{name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params: {iouType: CONST.IOU.TYPE.SPLIT}}, createMock({name: SCREENS.MONEY_REQUEST.CREATE, params})],
+ });
+
+ // When the real hook follows the tab's parent, all enum values must remain distinct.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then only the selected parent's consumed fields are returned.
+ expect(result.current).toStrictEqual({iouType, routeName: SCREENS.MONEY_REQUEST.CREATE});
+ });
+
+ it('selects parent index zero when a partial state has no index', () => {
+ // Given a partially rehydrated parent with two routes, an absent index means the first route.
+ const parentState = {
+ routes: [
+ {name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.TRACK}},
+ {name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params: {iouType: CONST.IOU.TYPE.SPLIT}},
+ ],
+ } satisfies PartialState;
+ mockGetState.mockReturnValue(parentState);
+
+ // When the tab's hook resolves its parent, missing index uses the existing zero fallback.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then the first route's exact type and name are the complete result.
+ expect(result.current).toStrictEqual({iouType: CONST.IOU.TYPE.TRACK, routeName: SCREENS.MONEY_REQUEST.CREATE});
+ });
+
+ it('returns an absent view when there is no parent navigator', () => {
+ // Given a tab with no direct params or parent, no create flow can be inferred.
+ mockGetParent.mockReturnValue(undefined);
+
+ // When the real hook resolves navigation, the missing parent must be safe.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then the empty name stays distinct from the create screen and iouType stays explicitly undefined.
+ expect(result.current).toStrictEqual({iouType: undefined, routeName: ''});
+ });
+
+ it.each<[string, NavigationState | PartialState | undefined]>([
+ ['missing state', undefined],
+ ['empty routes', {index: 0, routes: []}],
+ ['an out-of-range index', {index: 2, routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.CREATE}}]}],
+ ])('returns an absent view for %s', (_description, parentState) => {
+ // Given an absent or partial parent state without a selected route, no create route should be fabricated.
+ mockGetState.mockReturnValue(parentState);
+
+ // When the real hook looks up the selected parent, missing data must be safe.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then both absence fields are explicit, with no default CREATE name.
+ expect(result.current).toStrictEqual({iouType: undefined, routeName: ''});
+ });
+
+ it('keeps the empty name fallback when erased parent route data has no name', () => {
+ // Given an incomplete route at the parent-state boundary, its valid type must not imply a CREATE name.
+ mockGetState.mockReturnValue(createMock>({index: 0, routes: [{params: {iouType: CONST.IOU.TYPE.TRACK}}]}));
+
+ // When the real hook consumes the incomplete route, only the missing name uses the empty fallback.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then the supported type survives while the name remains absent, rather than fabricating a create flow.
+ expect(result.current).toStrictEqual({iouType: CONST.IOU.TYPE.TRACK, routeName: ''});
+ });
+
+ it('preserves a selected parent name when that route has no params', () => {
+ // Given a selected parent route without params, its identity still identifies the scan flow.
+ mockGetState.mockReturnValue({index: 0, routes: [{name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN}]});
+
+ // When the hook consumes that partial route, params absence must not discard the name.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then the parent name remains present with an explicit undefined IOU type.
+ expect(result.current).toStrictEqual({iouType: undefined, routeName: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN});
+ });
+
+ it.each(invalidScanParams)('returns undefined for direct $description at the erased params boundary', ({params}) => {
+ // Given erased direct params with an unsupported type, a valid parent must not override direct priority.
+ mockUseRoute.mockReturnValue(createMock>({name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params}));
+ mockGetState.mockReturnValue({index: 0, routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.CREATE}}]});
+
+ // When the real hook extracts the enum, it must reject only the unsupported IOU type.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then the entire direct view retains the route name and normalizes the type to undefined.
+ expect(result.current).toStrictEqual({iouType: undefined, routeName: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN});
+ });
+
+ it.each(invalidScanParams)('returns undefined for parent $description at the erased params boundary', ({params}) => {
+ // Given erased parent params with an unsupported type, the selected route name is still meaningful.
+ const parentState = {index: 0, routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params}]} satisfies PartialState;
+ mockGetState.mockReturnValue(parentState);
+
+ // When the real hook extracts the parent's enum, malformed values must not become a supported type.
+ const {result} = renderHook(() => useScanRouteParams());
+
+ // Then the complete parent view contains no invalid type or extra fields.
+ expect(result.current).toStrictEqual({iouType: undefined, routeName: SCREENS.MONEY_REQUEST.CREATE});
+ });
+});