From d6fa27acdb05cae1ee15028d5717f4449d69f59c Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Wed, 11 Mar 2026 18:54:40 +0100 Subject: [PATCH 01/12] feat: Add thumbnail support for receipts and improve image handling --- src/libs/DebugUtils.ts | 2 + src/libs/ReceiptUtils.ts | 4 +- src/libs/actions/IOU/index.ts | 13 +++++- .../cropImageToAspectRatio.ts | 16 ++++++- .../step/IOURequestStepScan/index.native.tsx | 44 ++++++++++--------- src/types/onyx/Transaction.ts | 3 ++ 6 files changed, 57 insertions(+), 25 deletions(-) diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 770f148035f6a..7823291b50dc6 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -799,6 +799,7 @@ function validateReportActionDraftProperty(key: keyof ReportAction, value: strin reservationList: 'string', isTestReceipt: 'boolean', isTestDriveReceipt: 'boolean', + thumbnail: 'string', }); case 'childRecentReceiptTransactionIDs': return validateObject>(value, {}, 'string'); @@ -1141,6 +1142,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) reservationList: 'array', isTestReceipt: 'boolean', isTestDriveReceipt: 'boolean', + thumbnail: 'string', }); case 'taxRate': return validateObject>(value, { diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index dceabfe0ca4f6..35c21bbae440e 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -62,9 +62,9 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPa return {image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction, filename}; } - // For local files, we won't have a thumbnail yet + // For local files, use the pre-generated thumbnail if available for fast preview if ((isReceiptImage || isReceiptPDF) && typeof path === 'string' && (path.startsWith('blob:') || path.startsWith('file:'))) { - return {image: path, isLocalFile: true, filename}; + return {thumbnail: transaction?.receipt?.thumbnail, image: path, isLocalFile: true, filename}; } if (isReceiptImage) { diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 2559891b5f3cc..000597e34e8a0 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1517,10 +1517,19 @@ function setMoneyRequestReportID(transactionID: string, reportID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {reportID}); } -function setMoneyRequestReceipt(transactionID: string, source: string, filename: string, isDraft: boolean, type?: string, isTestReceipt = false, isTestDriveReceipt = false) { +function setMoneyRequestReceipt( + transactionID: string, + source: string, + filename: string, + isDraft: boolean, + type?: string, + isTestReceipt = false, + isTestDriveReceipt = false, + thumbnail?: string, +) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { // isTestReceipt = false and isTestDriveReceipt = false are being converted to null because we don't really need to store it in Onyx in those cases - receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null}, + receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null, ...(thumbnail ? {thumbnail} : {})}, }); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts index 8e7a457283b77..0f9fe9742d563 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts @@ -1,3 +1,4 @@ +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; import type {Orientation} from 'react-native-vision-camera'; import cropOrRotateImage from '@libs/cropOrRotateImage'; @@ -78,5 +79,18 @@ function cropImageToAspectRatio( .catch(() => image); } +/** + * Generate a low-resolution thumbnail from an image URI. + * Used on native to avoid decoding the full 12MP camera photo on the confirmation page. + */ +function generateThumbnail(sourceUri: string, maxWidth = 512): Promise { + return ImageManipulator.manipulate(sourceUri) + .resize({width: maxWidth}) + .renderAsync() + .then((image) => image.saveAsync({compress: 0.8, format: SaveFormat.JPEG})) + .then((result) => result.uri) + .catch(() => undefined); +} + export type {ImageObject}; -export {calculateCropRect, cropImageToAspectRatio}; +export {calculateCropRect, cropImageToAspectRatio, generateThumbnail}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index c2044a833ed52..5548d40f94a06 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -47,6 +47,7 @@ import ROUTES from '@src/ROUTES'; import type {FileObject} from '@src/types/utils/Attachment'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import CameraPermission from './CameraPermission'; +import {generateThumbnail} from './cropImageToAspectRatio'; import NavigationAwareCamera from './NavigationAwareCamera/Camera'; import ReceiptPreviews from './ReceiptPreviews'; import type IOURequestStepScanProps from './types'; @@ -382,32 +383,35 @@ function IOURequestStepScan({ const transactionID = transaction?.transactionID ?? initialTransactionID; const source = getPhotoSource(photo.path); const filename = photo.path; - endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - const cameraFile = { - uri: source, - name: filename, - type: 'image/jpeg', - source, - }; + return generateThumbnail(source).then((thumbnailUri) => { + endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); + const cameraFile = { + uri: source, + name: filename, + type: 'image/jpeg', + source, + }; - if (isEditing) { - updateScanAndNavigate(cameraFile as FileObject, source); - return; - } + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg', false, false, thumbnailUri); - const newReceiptFiles = [...receiptFiles, {file: cameraFile as FileObject, source, transactionID}]; - setReceiptFiles(newReceiptFiles); + if (isEditing) { + updateScanAndNavigate(cameraFile as FileObject, source); + return; + } - if (isMultiScanEnabled) { - setDidCapturePhoto(false); - isCapturingPhoto.current = false; - return; - } + const newReceiptFiles = [...receiptFiles, {file: cameraFile as FileObject, source, transactionID}]; + setReceiptFiles(newReceiptFiles); - submitReceipts(newReceiptFiles); + if (isMultiScanEnabled) { + setDidCapturePhoto(false); + isCapturingPhoto.current = false; + return; + } + + submitReceipts(newReceiptFiles); + }); }) .catch((error: string) => { isCapturingPhoto.current = false; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index df5a83109aa0b..359bdb6c1c7a5 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -228,6 +228,9 @@ type Receipt = { /** Receipt is Test Drive testing receipt */ isTestDriveReceipt?: true; + + /** Local thumbnail URI for fast preview on confirmation page */ + thumbnail?: string; }; /** Model of route */ From 23bb98ff7d591eb0b30080342a74dc151cd7ccb9 Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Thu, 12 Mar 2026 18:49:17 +0100 Subject: [PATCH 02/12] Refactor IOURequestStepScan to generate thumbnails off the critical path. This change improves performance by decoupling thumbnail generation from the main receipt processing flow, ensuring that the UI remains responsive during photo capture. Updated logic for setting receipt files and handling editing state accordingly. --- .../step/IOURequestStepScan/index.native.tsx | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 0c1b47157717a..e004710095509 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -388,34 +388,39 @@ function IOURequestStepScan({ const source = getPhotoSource(photo.path); const filename = photo.path; - return generateThumbnail(source).then((thumbnailUri) => { - endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - const cameraFile = { - uri: source, - name: filename, - type: 'image/jpeg', - source, - }; + const cameraFile = { + uri: source, + name: filename, + type: 'image/jpeg', + source, + }; - setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg', false, false, thumbnailUri); + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); - if (isEditing) { - updateScanAndNavigate(cameraFile as FileObject, source); - return; + // Generate thumbnail off the critical path — update Onyx when ready + generateThumbnail(source).then((thumbnailUri) => { + if (thumbnailUri) { + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg', false, false, thumbnailUri); } + }); - const newReceiptFiles = [...receiptFiles, {file: cameraFile as FileObject, source, transactionID}]; - setReceiptFiles(newReceiptFiles); + if (isEditing) { + updateScanAndNavigate(cameraFile as FileObject, source); + return; + } - if (isMultiScanEnabled) { - setDidCapturePhoto(false); - isCapturingPhoto.current = false; - return; - } + const newReceiptFiles = [...receiptFiles, {file: cameraFile as FileObject, source, transactionID}]; + setReceiptFiles(newReceiptFiles); - submitReceipts(newReceiptFiles); - }); + if (isMultiScanEnabled) { + setDidCapturePhoto(false); + isCapturingPhoto.current = false; + return; + } + + submitReceipts(newReceiptFiles); }) .catch((error: string) => { isCapturingPhoto.current = false; From 6c74437146a3139c3827b818eb637df640e003bc Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Thu, 12 Mar 2026 18:49:39 +0100 Subject: [PATCH 03/12] resolve lint issue --- .../iou/request/step/IOURequestStepScan/index.native.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index e004710095509..e5c3f7e88c595 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -401,9 +401,10 @@ function IOURequestStepScan({ // Generate thumbnail off the critical path — update Onyx when ready generateThumbnail(source).then((thumbnailUri) => { - if (thumbnailUri) { - setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg', false, false, thumbnailUri); + if (!thumbnailUri) { + return; } + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg', false, false, thumbnailUri); }); if (isEditing) { From ddff15037863f39ff348ed19378a4762a613fe43 Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Tue, 24 Mar 2026 20:10:45 +0100 Subject: [PATCH 04/12] feat: Add local receipt thumbnail generation for improved image handling in MoneyRequestConfirmationListFooter --- .../MoneyRequestConfirmationListFooter.tsx | 14 +++-- src/hooks/useLocalReceiptThumbnail.ts | 53 +++++++++++++++++++ .../step/IOURequestStepScan/index.native.tsx | 9 ---- 3 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useLocalReceiptThumbnail.ts diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 7819c64f58ad4..db5312694996d 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -10,6 +10,7 @@ import type {ValueOf} from 'type-fest'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useLocalReceiptThumbnail from '@hooks/useLocalReceiptThumbnail'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOutstandingReports from '@hooks/useOutstandingReports'; @@ -446,6 +447,13 @@ function MoneyRequestConfirmationListFooter({ const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); + const {thumbnailUri} = useLocalReceiptThumbnail(resolvedReceiptImage as string, !!isLocalFile); + + // For local files: use thumbnail when ready, show empty string (loading state) while generating + // For remote files: use existing behavior unchanged + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const effectiveReceiptSource = isLocalFile ? thumbnailUri || '' : resolvedThumbnail || resolvedReceiptImage || ''; + const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy; // Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate const shouldShowTimeRequestFields = isTimeRequest && action === CONST.IOU.ACTION.CREATE; @@ -1142,8 +1150,7 @@ function MoneyRequestConfirmationListFooter({ > (); + +/** + * Generates a low-resolution thumbnail for a local receipt image. + * State updates are wrapped in startTransition so React deprioritizes + * the re-render and doesn't interrupt navigation animations. + */ +function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: boolean): {thumbnailUri: string | undefined; isGenerating: boolean} { + const [thumbnailUri, setThumbnailUri] = useState(() => (sourceUri ? thumbnailCache.get(sourceUri) : undefined)); + const [isGenerating, setIsGenerating] = useState(false); + const [, startTransition] = useTransition(); + + useEffect(() => { + if (!sourceUri || !isLocalFile) { + return; + } + + const cached = thumbnailCache.get(sourceUri); + if (cached) { + setThumbnailUri(cached); + return; + } + + setIsGenerating(true); + + let cancelled = false; + generateThumbnail(sourceUri).then((uri) => { + if (cancelled) { + return; + } + if (uri) { + thumbnailCache.set(sourceUri, uri); + } + startTransition(() => { + if (uri) { + setThumbnailUri(uri); + } + setIsGenerating(false); + }); + }); + + return () => { + cancelled = true; + }; + }, [sourceUri, isLocalFile, startTransition]); + + return {thumbnailUri, isGenerating}; +} + +export default useLocalReceiptThumbnail; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 72e8a022e5b6e..9dfc0e55e6337 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -50,7 +50,6 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; import CameraPermission from './CameraPermission'; import NavigationAwareCamera from './components/NavigationAwareCamera/Camera'; import ReceiptPreviews from './components/ReceiptPreviews'; -import {generateThumbnail} from './cropImageToAspectRatio'; import useMobileReceiptScan from './hooks/useMobileReceiptScan'; import useReceiptScan from './hooks/useReceiptScan'; import type IOURequestStepScanProps from './types'; @@ -401,14 +400,6 @@ function IOURequestStepScan({ setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); - // Generate thumbnail off the critical path — update Onyx when ready - generateThumbnail(source).then((thumbnailUri) => { - if (!thumbnailUri) { - return; - } - setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg', false, false, thumbnailUri); - }); - if (isEditing) { updateScanAndNavigate(cameraFile as FileObject, source); return; From f4cf69d5099a46a5d5a2825a6db108c4e7912013 Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Tue, 24 Mar 2026 21:21:30 +0100 Subject: [PATCH 05/12] refactor: Enhance local receipt thumbnail handling in MoneyRequestConfirmationListFooter --- src/components/MoneyRequestConfirmationListFooter.tsx | 8 +++++--- src/hooks/useLocalReceiptThumbnail.ts | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index db5312694996d..917ed02f47d4f 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -447,12 +447,13 @@ function MoneyRequestConfirmationListFooter({ const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); - const {thumbnailUri} = useLocalReceiptThumbnail(resolvedReceiptImage as string, !!isLocalFile); + const [receiptImageLoaded, setReceiptImageLoaded] = useState(false); + const {thumbnailUri} = useLocalReceiptThumbnail(resolvedReceiptImage as string, !!isLocalFile, receiptImageLoaded); - // For local files: use thumbnail when ready, show empty string (loading state) while generating + // For local files: use thumbnail when ready, fall back to full-resolution image while generating // For remote files: use existing behavior unchanged // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const effectiveReceiptSource = isLocalFile ? thumbnailUri || '' : resolvedThumbnail || resolvedReceiptImage || ''; + const effectiveReceiptSource = isLocalFile ? thumbnailUri || resolvedReceiptImage || '' : resolvedThumbnail || resolvedReceiptImage || ''; const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy; // Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate @@ -1037,6 +1038,7 @@ function MoneyRequestConfirmationListFooter({ const [compactReceiptContainerWidth, setCompactReceiptContainerWidth] = useState(0); const hasEndedReceiptLoadSpan = useRef(false); const handleReceiptLoad = useCallback((event?: {nativeEvent: {width: number; height: number}}) => { + setReceiptImageLoaded(true); if (!hasEndedReceiptLoadSpan.current) { hasEndedReceiptLoadSpan.current = true; endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD); diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index 1b3db8c23790f..d09da674b4605 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -8,13 +8,13 @@ const thumbnailCache = new Map(); * State updates are wrapped in startTransition so React deprioritizes * the re-render and doesn't interrupt navigation animations. */ -function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: boolean): {thumbnailUri: string | undefined; isGenerating: boolean} { +function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: boolean, enabled = true): {thumbnailUri: string | undefined; isGenerating: boolean} { const [thumbnailUri, setThumbnailUri] = useState(() => (sourceUri ? thumbnailCache.get(sourceUri) : undefined)); const [isGenerating, setIsGenerating] = useState(false); const [, startTransition] = useTransition(); useEffect(() => { - if (!sourceUri || !isLocalFile) { + if (!sourceUri || !isLocalFile || !enabled) { return; } @@ -45,7 +45,7 @@ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: bo return () => { cancelled = true; }; - }, [sourceUri, isLocalFile, startTransition]); + }, [sourceUri, isLocalFile, startTransition, enabled]); return {thumbnailUri, isGenerating}; } From ecc9d6c8cb988790ec6ea7af2ccd4228bc3229fd Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Fri, 27 Mar 2026 17:39:04 +0100 Subject: [PATCH 06/12] resolve comments --- src/hooks/useLocalReceiptThumbnail.ts | 1 + src/libs/actions/IOU/index.ts | 9 ++++++++- src/libs/fileDownload/FileUtils.ts | 1 + .../IOURequestStepScan/cropImageToAspectRatio.ts | 12 +++++++++--- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index d09da674b4605..9176ce54f02eb 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -44,6 +44,7 @@ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: bo return () => { cancelled = true; + thumbnailCache.delete(sourceUri); }; }, [sourceUri, isLocalFile, startTransition, enabled]); diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 87cbf3c59ccc3..30656abc3c439 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1577,7 +1577,14 @@ function setMoneyRequestReceipt( ) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { // isTestReceipt = false and isTestDriveReceipt = false are being converted to null because we don't really need to store it in Onyx in those cases - receipt: {source, filename, type: type ?? '', isTestReceipt: isTestReceipt ? true : null, isTestDriveReceipt: isTestDriveReceipt ? true : null, ...(thumbnail ? {thumbnail} : {})}, + receipt: { + source, + filename, + type: type ?? '', + isTestReceipt: isTestReceipt ? true : null, + isTestDriveReceipt: isTestDriveReceipt ? true : null, + thumbnail, + }, }); } diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index bc26a58747ce1..1bb619b4bc007 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -903,5 +903,6 @@ export { getFilesFromClipboardEvent, cleanFileObject, cleanFileObjectName, + JPEG_QUALITY, }; export type {FileValidationError}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts index 0f9fe9742d563..afe8bc5cd4cdb 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts @@ -1,8 +1,10 @@ import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; import type {Orientation} from 'react-native-vision-camera'; +import Log from '@libs/__mocks__/Log'; import cropOrRotateImage from '@libs/cropOrRotateImage'; import getDeviceOrientationAwareImageSize from '@libs/cropOrRotateImage/getDeviceOrientationAwareImageSize'; +import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; import type {FileObject} from '@src/types/utils/Attachment'; type ImageObject = { @@ -79,17 +81,21 @@ function cropImageToAspectRatio( .catch(() => image); } +const THUMBNAIL_MAX_WIDTH = 512; /** * Generate a low-resolution thumbnail from an image URI. * Used on native to avoid decoding the full 12MP camera photo on the confirmation page. */ -function generateThumbnail(sourceUri: string, maxWidth = 512): Promise { +function generateThumbnail(sourceUri: string, maxWidth = THUMBNAIL_MAX_WIDTH): Promise { return ImageManipulator.manipulate(sourceUri) .resize({width: maxWidth}) .renderAsync() - .then((image) => image.saveAsync({compress: 0.8, format: SaveFormat.JPEG})) + .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG})) .then((result) => result.uri) - .catch(() => undefined); + .catch((error) => { + Log.warn(`Failed to generate thumbnail: ${error}`); + return undefined; + }); } export type {ImageObject}; From 05b4b4067d0e247fd6cc3042f454c5b4721f70b5 Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Fri, 27 Mar 2026 17:46:23 +0100 Subject: [PATCH 07/12] fix lint --- src/components/MoneyRequestConfirmationListFooter.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 09aea6d21f6b2..561ecc77228de 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1311,10 +1311,11 @@ function MoneyRequestConfirmationListFooter({ receiptFilename, translate, shouldDisplayReceipt, - effectiveReceiptSource, + resolvedReceiptImage, onPDFLoadError, onPDFPassword, isThumbnail, + effectiveReceiptSource, receiptThumbnail, fileExtension, isDistanceRequest, From fd0e6fe2aba2e4d137fc136358beb782889ee6ce Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Mon, 30 Mar 2026 18:49:26 +0200 Subject: [PATCH 08/12] Enhance thumbnail generation logic and update logging import. Added support for async result handling in useLocalReceiptThumbnail hook. Updated cspell configuration to include new term "deprioritizes". --- cspell.json | 3 +- src/hooks/useLocalReceiptThumbnail.ts | 49 ++++++++++--------- .../cropImageToAspectRatio.ts | 2 +- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/cspell.json b/cspell.json index 01a04820149f0..c2c69b2f3fa9d 100644 --- a/cspell.json +++ b/cspell.json @@ -866,7 +866,8 @@ "zxcv", "zxldvw", "مثال", - "PINATM" + "PINATM", + "deprioritizes" ], "ignorePaths": [ "src/languages/de.ts", diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index 9176ce54f02eb..abc7f95240241 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -9,38 +9,43 @@ const thumbnailCache = new Map(); * the re-render and doesn't interrupt navigation animations. */ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: boolean, enabled = true): {thumbnailUri: string | undefined; isGenerating: boolean} { - const [thumbnailUri, setThumbnailUri] = useState(() => (sourceUri ? thumbnailCache.get(sourceUri) : undefined)); - const [isGenerating, setIsGenerating] = useState(false); + // Stores the async generation result, tagged with the sourceUri it was generated for. + // This avoids synchronous setState inside effects — all derivation happens during render. + const [asyncResult, setAsyncResult] = useState<{source: string; uri?: string; done: boolean} | undefined>(); const [, startTransition] = useTransition(); - useEffect(() => { - if (!sourceUri || !isLocalFile || !enabled) { - return; - } + // Resolve cached thumbnails synchronously during render + const cachedUri = sourceUri ? thumbnailCache.get(sourceUri) : undefined; + const resultForCurrentSource = asyncResult?.source === sourceUri ? asyncResult : undefined; + const thumbnailUri = cachedUri ?? resultForCurrentSource?.uri; + + const shouldGenerate = !!sourceUri && isLocalFile && enabled && !cachedUri; + const isGenerating = shouldGenerate && !resultForCurrentSource?.done; - const cached = thumbnailCache.get(sourceUri); - if (cached) { - setThumbnailUri(cached); + useEffect(() => { + if (!sourceUri || !isLocalFile || !enabled || thumbnailCache.has(sourceUri)) { return; } - setIsGenerating(true); - let cancelled = false; - generateThumbnail(sourceUri).then((uri) => { - if (cancelled) { - return; - } - if (uri) { - thumbnailCache.set(sourceUri, uri); - } - startTransition(() => { + generateThumbnail(sourceUri) + .then((uri) => { + if (cancelled) { + return; + } if (uri) { - setThumbnailUri(uri); + thumbnailCache.set(sourceUri, uri); + } + startTransition(() => { + setAsyncResult({source: sourceUri, uri: uri ?? undefined, done: true}); + }); + }) + .catch(() => { + if (cancelled) { + return; } - setIsGenerating(false); + setAsyncResult({source: sourceUri, done: true}); }); - }); return () => { cancelled = true; diff --git a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts index afe8bc5cd4cdb..0116afacb00f9 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts @@ -1,10 +1,10 @@ import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import ImageSize from 'react-native-image-size'; import type {Orientation} from 'react-native-vision-camera'; -import Log from '@libs/__mocks__/Log'; import cropOrRotateImage from '@libs/cropOrRotateImage'; import getDeviceOrientationAwareImageSize from '@libs/cropOrRotateImage/getDeviceOrientationAwareImageSize'; import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; +import Log from '@libs/Log'; import type {FileObject} from '@src/types/utils/Attachment'; type ImageObject = { From f8cac08d9140c9c585a2236274855bb6a48795f6 Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Mon, 30 Mar 2026 19:05:25 +0200 Subject: [PATCH 09/12] Fix: Update style property to use absoluteFill for camera overlay in IOURequestStepScan --- src/pages/iou/request/step/IOURequestStepScan/index.native.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 9dfc0e55e6337..167fefa39272a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -523,7 +523,7 @@ function IOURequestStepScan({ From 8e8bcf01939edbf1af420361076eae69696f8e74 Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Sat, 4 Apr 2026 20:59:59 +0200 Subject: [PATCH 10/12] Implement thumbnail pre-generation for improved performance in confirmation screen. Update `useLocalReceiptThumbnail` to manage thumbnail caching and references, preventing source swaps. Adjust `MoneyRequestConfirmationListFooter` to utilize cached thumbnails effectively. Ensure seamless integration in `IOURequestStepScan` for enhanced user experience. --- .../MoneyRequestConfirmationListFooter.tsx | 18 +++-- src/hooks/useLocalReceiptThumbnail.ts | 74 ++++++++++++++++--- .../step/IOURequestStepScan/index.native.tsx | 7 ++ 3 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index d1ad270618391..7be5d4d5ebde0 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -470,13 +470,18 @@ function MoneyRequestConfirmationListFooter({ const resolvedThumbnail = isLocalFile ? receiptThumbnail : tryResolveUrlFromApiRoot(receiptThumbnail ?? ''); const resolvedReceiptImage = isLocalFile ? receiptImage : tryResolveUrlFromApiRoot(receiptImage ?? ''); - const [receiptImageLoaded, setReceiptImageLoaded] = useState(false); - const {thumbnailUri} = useLocalReceiptThumbnail(resolvedReceiptImage as string, !!isLocalFile, receiptImageLoaded); - - // For local files: use thumbnail when ready, fall back to full-resolution image while generating - // For remote files: use existing behavior unchanged + const {thumbnailUri} = useLocalReceiptThumbnail(resolvedReceiptImage as string, !!isLocalFile); + + // For local files: use the pre-generated thumbnail if it was ready by first render. + // If the thumbnail arrives late we keep showing the full-res image to avoid a + // visible source swap (flash). For remote files: existing behavior unchanged. + const initialLocalSourceRef = useRef(undefined); + if (isLocalFile && initialLocalSourceRef.current === undefined) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + initialLocalSourceRef.current = thumbnailUri || String(resolvedReceiptImage ?? '') || ''; + } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const effectiveReceiptSource = isLocalFile ? thumbnailUri || resolvedReceiptImage || '' : resolvedThumbnail || resolvedReceiptImage || ''; + const effectiveReceiptSource = isLocalFile ? initialLocalSourceRef.current || '' : resolvedThumbnail || resolvedReceiptImage || ''; const shouldNavigateToUpgradePath = !policyForMovingExpensesID && !shouldSelectPolicy; // Time requests appear as regular expenses after they're created, with editable amount and merchant, not hours and rate @@ -1162,7 +1167,6 @@ function MoneyRequestConfirmationListFooter({ const [compactReceiptContainerWidth, setCompactReceiptContainerWidth] = useState(0); const hasEndedReceiptLoadSpan = useRef(false); const handleReceiptLoad = useCallback((event?: {nativeEvent: {width: number; height: number}}) => { - setReceiptImageLoaded(true); if (!hasEndedReceiptLoadSpan.current) { hasEndedReceiptLoadSpan.current = true; endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD); diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index abc7f95240241..707b051fa4e60 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -1,29 +1,79 @@ -import {useEffect, useState, useTransition} from 'react'; +import {useEffect, useRef, useState, useTransition} from 'react'; import {generateThumbnail} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; const thumbnailCache = new Map(); +/** Track how many mounted hook instances reference each sourceUri */ +const thumbnailRefCount = new Map(); + +function retainUri(uri: string) { + thumbnailRefCount.set(uri, (thumbnailRefCount.get(uri) ?? 0) + 1); +} + +function releaseUri(uri: string) { + const count = (thumbnailRefCount.get(uri) ?? 1) - 1; + if (count <= 0) { + thumbnailRefCount.delete(uri); + thumbnailCache.delete(uri); + } else { + thumbnailRefCount.set(uri, count); + } +} + +/** + * Pre-populate the thumbnail cache so the confirm screen can use it + * synchronously on first render, avoiding any source swap / flash. + */ +function pregenerateThumbnail(sourceUri: string): Promise { + if (thumbnailCache.has(sourceUri)) { + return Promise.resolve(thumbnailCache.get(sourceUri)); + } + return generateThumbnail(sourceUri).then((uri) => { + if (uri) { + thumbnailCache.set(sourceUri, uri); + } + return uri; + }); +} /** - * Generates a low-resolution thumbnail for a local receipt image. - * State updates are wrapped in startTransition so React deprioritizes - * the re-render and doesn't interrupt navigation animations. + * Returns a cached low-resolution thumbnail for a local receipt image. + * The thumbnail should be pre-generated via `pregenerateThumbnail` before + * navigating to the confirm screen. If it wasn't, this hook generates it + * as a fallback, but in that case a source swap (flash) may occur. */ -function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: boolean, enabled = true): {thumbnailUri: string | undefined; isGenerating: boolean} { - // Stores the async generation result, tagged with the sourceUri it was generated for. - // This avoids synchronous setState inside effects — all derivation happens during render. +function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: boolean): {thumbnailUri: string | undefined; isGenerating: boolean} { const [asyncResult, setAsyncResult] = useState<{source: string; uri?: string; done: boolean} | undefined>(); const [, startTransition] = useTransition(); + const retainedUriRef = useRef(undefined); - // Resolve cached thumbnails synchronously during render + // Resolve cached thumbnails synchronously during render (fast path) const cachedUri = sourceUri ? thumbnailCache.get(sourceUri) : undefined; const resultForCurrentSource = asyncResult?.source === sourceUri ? asyncResult : undefined; const thumbnailUri = cachedUri ?? resultForCurrentSource?.uri; - const shouldGenerate = !!sourceUri && isLocalFile && enabled && !cachedUri; + const shouldGenerate = !!sourceUri && isLocalFile && !cachedUri; const isGenerating = shouldGenerate && !resultForCurrentSource?.done; + // Retain / release the cache entry so it lives as long as at least one + // mounted hook instance references it, and is cleaned up after the last + // consumer unmounts. + useEffect(() => { + if (!sourceUri || !isLocalFile) { + return; + } + + retainUri(sourceUri); + retainedUriRef.current = sourceUri; + + return () => { + releaseUri(sourceUri); + retainedUriRef.current = undefined; + }; + }, [sourceUri, isLocalFile]); + + // Fallback: generate if not already in cache (e.g. gallery pick path) useEffect(() => { - if (!sourceUri || !isLocalFile || !enabled || thumbnailCache.has(sourceUri)) { + if (!sourceUri || !isLocalFile || thumbnailCache.has(sourceUri)) { return; } @@ -49,11 +99,11 @@ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: bo return () => { cancelled = true; - thumbnailCache.delete(sourceUri); }; - }, [sourceUri, isLocalFile, startTransition, enabled]); + }, [sourceUri, isLocalFile, startTransition]); return {thumbnailUri, isGenerating}; } +export {pregenerateThumbnail}; export default useLocalReceiptThumbnail; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 36d65eee6c89a..43f012e8cff7a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -21,6 +21,7 @@ import Text from '@components/Text'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import {pregenerateThumbnail} from '@hooks/useLocalReceiptThumbnail'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -421,6 +422,12 @@ function IOURequestStepScan({ return; } + // Pre-generate the thumbnail and cache it so the confirm screen + // can use it synchronously on first render (no source swap / flash). + // We don't block on it — if it finishes before navigation, great; + // if not, the hook's fallback path will handle it. + pregenerateThumbnail(source); + // Defer navigation by one frame so React renders the frozen camera // state (didCapturePhoto=true) before the screen transitions away. requestAnimationFrame(() => submitReceipts(newReceiptFiles)); From 5beb1fa8795e01b3364aa6be9c124ce451092d57 Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Tue, 7 Apr 2026 11:07:09 +0200 Subject: [PATCH 11/12] Enhance thumbnail generation for confirmation screen by reducing max width to 256px for faster decoding. Implement prefetching of thumbnails to eliminate latency during display. Update IOURequestStepScan to handle thumbnail generation and navigation more efficiently. --- src/hooks/useLocalReceiptThumbnail.ts | 4 ++++ .../cropImageToAspectRatio.ts | 3 ++- .../step/IOURequestStepScan/index.native.tsx | 23 ++++++++++--------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index 707b051fa4e60..905c0ed42133d 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -1,4 +1,5 @@ import {useEffect, useRef, useState, useTransition} from 'react'; +import {Image} from 'react-native'; import {generateThumbnail} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; const thumbnailCache = new Map(); @@ -30,6 +31,9 @@ function pregenerateThumbnail(sourceUri: string): Promise { return generateThumbnail(sourceUri).then((uri) => { if (uri) { thumbnailCache.set(sourceUri, uri); + // Pre-decode the thumbnail in the native image pipeline so the + // confirmation screen can display it instantly without decode latency. + Image.prefetch(uri); } return uri; }); diff --git a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts index 0116afacb00f9..7b889fb616a90 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio.ts @@ -81,10 +81,11 @@ function cropImageToAspectRatio( .catch(() => image); } -const THUMBNAIL_MAX_WIDTH = 512; +const THUMBNAIL_MAX_WIDTH = 256; /** * Generate a low-resolution thumbnail from an image URI. * Used on native to avoid decoding the full 12MP camera photo on the confirmation page. + * 256px is sufficient for the confirmation screen preview and decodes ~4x faster than 512px. */ function generateThumbnail(sourceUri: string, maxWidth = THUMBNAIL_MAX_WIDTH): Promise { return ImageManipulator.manipulate(sourceUri) diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 73cb4402abf29..972070e79424e 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -175,6 +175,10 @@ function IOURequestStepScan({ } endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); + // Preload the confirmation screen module so its JS is parsed and ready + // when we navigate after capture — eliminates cold-start module load cost. + require('../IOURequestStepConfirmation'); + // Pre-create upload directory to avoid latency during capture const path = getReceiptsUploadFolderPath(); ReactNativeBlobUtil.fs @@ -332,9 +336,8 @@ function IOURequestStepScan({ source, }; - setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); - if (isEditing) { + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); updateScanAndNavigate(cameraFile as FileObject, source); return; } @@ -343,20 +346,18 @@ function IOURequestStepScan({ setReceiptFiles(newReceiptFiles); if (isMultiScanEnabled) { + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); setDidCapturePhoto(false); isCapturingPhoto.current = false; return; } - // Pre-generate the thumbnail and cache it so the confirm screen - // can use it synchronously on first render (no source swap / flash). - // We don't block on it — if it finishes before navigation, great; - // if not, the hook's fallback path will handle it. - pregenerateThumbnail(source); - - // Defer navigation by one frame so React renders the frozen camera - // state (didCapturePhoto=true) before the screen transitions away. - requestAnimationFrame(() => submitReceipts(newReceiptFiles)); + // Fire Onyx merge immediately (non-blocking) while we await thumbnail generation. + // Both run in parallel — navigation proceeds once the thumbnail is cached. + setMoneyRequestReceipt(transactionID, source, filename, !isEditing, 'image/jpeg'); + pregenerateThumbnail(source).then(() => { + submitReceipts(newReceiptFiles); + }); }) .catch((error: string) => { isCapturingPhoto.current = false; From 512c4818f8c94fcc34fad60e3a76d6aa1bb38c6e Mon Sep 17 00:00:00 2001 From: kubabutkiewicz Date: Tue, 7 Apr 2026 22:10:25 +0200 Subject: [PATCH 12/12] fix lint --- src/components/MoneyRequestConfirmationListFooter.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 317404ccfe663..1ea1bc03d494a 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1,4 +1,4 @@ -import {emailSelector} from '@selectors/Session'; +import {accountIDSelector, emailSelector} from '@selectors/Session'; import {format} from 'date-fns'; import {Str} from 'expensify-common'; import {deepEqual} from 'fast-equals'; @@ -366,9 +366,7 @@ function MoneyRequestConfirmationListFooter({ const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); - const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, { - selector: (session) => session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - }); + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const isCreatingTrackExpense = action === CONST.IOU.ACTION.CREATE && iouType === CONST.IOU.TYPE.TRACK;