From 872259d9df03e6630acd9f15ea2c6785b0cca3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 24 Apr 2026 13:03:34 +0200 Subject: [PATCH 1/3] fix: prevent odometer confirmation freeze (stuck on E screen) on browser refresh --- .../index.native.ts | 5 +- .../index.ts | 28 ++++- .../step/IOURequestStepConfirmation.tsx | 6 +- .../confirmation/OdometerReceiptStitcher.tsx | 106 ++++++------------ 4 files changed, 65 insertions(+), 80 deletions(-) diff --git a/src/hooks/useRestartOnOdometerImagesFailure/index.native.ts b/src/hooks/useRestartOnOdometerImagesFailure/index.native.ts index 9db08faa2152..e3ebe5bd8640 100644 --- a/src/hooks/useRestartOnOdometerImagesFailure/index.native.ts +++ b/src/hooks/useRestartOnOdometerImagesFailure/index.native.ts @@ -1,5 +1,6 @@ -// On native blob:// URLs don't exist, so there is nothing to check +// On native blob:// URLs don't exist, so there is nothing to check — +// callers can always proceed with blob-dependent side-effects (like stitching) -const useRestartOnOdometerImagesFailure = () => {}; +const useRestartOnOdometerImagesFailure = (): {hasVerifiedBlobs: boolean} => ({hasVerifiedBlobs: true}); export default useRestartOnOdometerImagesFailure; diff --git a/src/hooks/useRestartOnOdometerImagesFailure/index.ts b/src/hooks/useRestartOnOdometerImagesFailure/index.ts index 68f3ed0f3c20..5eb69b103438 100644 --- a/src/hooks/useRestartOnOdometerImagesFailure/index.ts +++ b/src/hooks/useRestartOnOdometerImagesFailure/index.ts @@ -1,4 +1,4 @@ -import {useEffect, useRef} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useOnyx from '@hooks/useOnyx'; import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt'; @@ -17,9 +17,27 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; // This is because until the request is saved, the image files are only stored in the browser's memory as blob:// URLs // and if the browser is refreshed, then the images cease to exist. // The best way for the user to recover from this is to start over from the start of the request process. -const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry, reportID: string, iouType: IOUType, backToReport: string | undefined, onBackupHandled?: () => void) => { +// Returns `hasVerifiedBlobs` so callers can gate dependent side-effects (e.g. odometer image stitching) +// until this check has confirmed the blobs are still readable. When there are no blob URLs to verify +// (e.g. native file:// paths or remote URLs), `hasVerifiedBlobs` is `true` as soon as Onyx has loaded. +const useRestartOnOdometerImagesFailure = ( + transaction: OnyxEntry, + reportID: string, + iouType: IOUType, + backToReport: string | undefined, + onBackupHandled?: () => void, +): {hasVerifiedBlobs: boolean} => { const [, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const hasCheckedRef = useRef(false); + const [asyncVerificationPassed, setAsyncVerificationPassed] = useState(false); + + const hasBlobUrls = useMemo(() => { + if (!transaction) { + return false; + } + const paths = [getOdometerImageUri(transaction.comment?.odometerStartImage), getOdometerImageUri(transaction.comment?.odometerEndImage), transaction.receipt?.source?.toString()]; + return paths.some((path) => !!path && path.startsWith('blob:')); + }, [transaction]); useEffect(() => { if (!transaction || isLoadingOnyxValue(draftTransactionsMetadata)) { @@ -76,6 +94,7 @@ const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry, ).then((results) => { const canBeRead = results.every(Boolean); if (canBeRead) { + setAsyncVerificationPassed(true); return; } @@ -84,6 +103,11 @@ const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry, navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport); }); }, [draftTransactionsMetadata, transaction, iouType, reportID, backToReport, onBackupHandled]); + + const isOnyxLoading = isLoadingOnyxValue(draftTransactionsMetadata); + const hasVerifiedBlobs = !!transaction && !isOnyxLoading && (!hasBlobUrls || asyncVerificationPassed); + + return {hasVerifiedBlobs}; }; export default useRestartOnOdometerImagesFailure; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 3abe6758135d..9c0e20c7e163 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -240,7 +240,7 @@ function IOURequestStepConfirmation({ const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; - useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, backToReport); + const {hasVerifiedBlobs} = useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, backToReport); // Pre-insert Search is only useful for flows whose submit ends in handleNavigateAfterExpenseCreate // (which navigates to Search). Flows that use dismissModalAndOpenReportInInboxTab (PAY, @@ -553,9 +553,7 @@ function IOURequestStepConfirmation({ odometerStartImage={odometerStartImage} odometerEndImage={odometerEndImage} transaction={transaction} - reportID={reportID} - backToReport={backToReport} - iouType={iouType} + hasVerifiedBlobs={hasVerifiedBlobs} onStitchingChange={setIsStitchingReceipt} onStitchError={setStitchError} /> diff --git a/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx index a27c431686c6..8dc28a02243c 100644 --- a/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx +++ b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx @@ -2,15 +2,12 @@ import {useIsFocused} from '@react-navigation/native'; import {useEffect, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; -import clearOdometerDraftTransactionState from '@libs/actions/OdometerTransactionUtils'; -import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; import Log from '@libs/Log'; import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils'; import stitchOdometerImages from '@libs/stitchOdometerImages'; import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; -import {checkIfLocalFileIsAccessible, setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; +import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; import CONST from '@src/CONST'; -import type {IOUType} from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -19,9 +16,7 @@ type OdometerReceiptStitcherProps = { odometerStartImage: FileObject | string | null | undefined; odometerEndImage: FileObject | string | null | undefined; transaction: OnyxEntry; - reportID: string; - backToReport: string | undefined; - iouType: IOUType; + hasVerifiedBlobs: boolean; onStitchingChange: (isStitching: boolean) => void; onStitchError: (error: string) => void; }; @@ -37,9 +32,7 @@ function OdometerReceiptStitcher({ odometerStartImage, odometerEndImage, transaction, - reportID, - backToReport, - iouType, + hasVerifiedBlobs, onStitchingChange, onStitchError, }: OdometerReceiptStitcherProps) { @@ -51,7 +44,10 @@ function OdometerReceiptStitcher({ } | null>(null); useEffect(() => { - if (!isOdometerDistanceRequest || !isFocused || !transaction) { + // Wait until useRestartOnOdometerImagesFailure has confirmed the blob URLs are still + // readable. Stitching a dead blob after a browser refresh would race with that hook's + // redirect and leave the UI stuck on the E screen + if (!isOdometerDistanceRequest || !isFocused || !transaction || !hasVerifiedBlobs) { return; } @@ -83,76 +79,42 @@ function OdometerReceiptStitcher({ onStitchingChange(true); onStitchError(''); - const runStitch = () => { - startSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, { - name: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, - op: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, - }); + startSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, { + name: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, + op: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, + }); - stitchOdometerImages(odometerStartImage, odometerEndImage) - .then((stitchedImage) => { - if (ignore || !stitchedImage) { - cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH); - return; - } - setMoneyRequestReceipt(transaction.transactionID, getOdometerImageUri(stitchedImage), getOdometerImageName(stitchedImage), true, getOdometerImageType(stitchedImage)); - lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; - endSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH); - }) - .catch((error: unknown) => { + stitchOdometerImages(odometerStartImage, odometerEndImage) + .then((stitchedImage) => { + if (ignore || !stitchedImage) { cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH); - if (ignore) { - return; - } - Log.warn('stitchOdometerImages failed', {error}); - onStitchError(translate('iou.error.stitchOdometerImagesFailed')); - }) - .finally(() => { - if (ignore) { - return; - } - onStitchingChange(false); - }); - }; - - // Pre-flight: verify blob URLs haven't expired before attempting to stitch. - const localImages = [ - {uri: startUri, image: odometerStartImage}, - {uri: endUri, image: odometerEndImage}, - ].filter((item): item is {uri: string; image: typeof odometerStartImage} => !!item.uri && item.uri.startsWith('blob:')); - - let hasExpiredImages = false; - Promise.all( - localImages.map(({uri, image}) => - checkIfLocalFileIsAccessible( - getOdometerImageName(image), - uri, - typeof image === 'object' ? image?.type : undefined, - () => {}, - () => { - hasExpiredImages = true; - }, - ), - ), - ).then(() => { - if (ignore) { - return; - } - if (hasExpiredImages) { + return; + } + setMoneyRequestReceipt(transaction.transactionID, getOdometerImageUri(stitchedImage), getOdometerImageName(stitchedImage), true, getOdometerImageType(stitchedImage)); + lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; + endSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH); + }) + .catch((error: unknown) => { + cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH); + if (ignore) { + return; + } + Log.warn('stitchOdometerImages failed', {error}); + onStitchError(translate('iou.error.stitchOdometerImagesFailed')); + }) + .finally(() => { + if (ignore) { + return; + } onStitchingChange(false); - clearOdometerDraftTransactionState(transaction); - navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport); - return; - } - runStitch(); - }); + }); return () => { ignore = true; onStitchingChange(false); cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH); }; - }, [isOdometerDistanceRequest, isFocused, odometerStartImage, odometerEndImage, transaction, reportID, backToReport, translate, iouType, onStitchingChange, onStitchError]); + }, [isOdometerDistanceRequest, isFocused, odometerStartImage, odometerEndImage, transaction, hasVerifiedBlobs, translate, onStitchingChange, onStitchError]); return null; } From c69202285cdc9ee7ebfe0af228bcb75e481f2c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 24 Apr 2026 13:36:03 +0200 Subject: [PATCH 2/3] fix: remove redundant useMemo in useRestartOnOdometerImagesFailure --- src/hooks/useRestartOnOdometerImagesFailure/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useRestartOnOdometerImagesFailure/index.ts b/src/hooks/useRestartOnOdometerImagesFailure/index.ts index 5eb69b103438..c10cec9238c9 100644 --- a/src/hooks/useRestartOnOdometerImagesFailure/index.ts +++ b/src/hooks/useRestartOnOdometerImagesFailure/index.ts @@ -1,4 +1,4 @@ -import {useEffect, useMemo, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useOnyx from '@hooks/useOnyx'; import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt'; @@ -31,13 +31,13 @@ const useRestartOnOdometerImagesFailure = ( const hasCheckedRef = useRef(false); const [asyncVerificationPassed, setAsyncVerificationPassed] = useState(false); - const hasBlobUrls = useMemo(() => { + const hasBlobUrls = (() => { if (!transaction) { return false; } const paths = [getOdometerImageUri(transaction.comment?.odometerStartImage), getOdometerImageUri(transaction.comment?.odometerEndImage), transaction.receipt?.source?.toString()]; return paths.some((path) => !!path && path.startsWith('blob:')); - }, [transaction]); + })(); useEffect(() => { if (!transaction || isLoadingOnyxValue(draftTransactionsMetadata)) { From b7870acc94244a4a6d1828e3324c94c9b253a5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 24 Apr 2026 13:39:25 +0200 Subject: [PATCH 3/3] fix: block odometer Create button during blob verification --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 9c0e20c7e163..3000faa18340 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -646,7 +646,7 @@ function IOURequestStepConfirmation({ isPolicyExpenseChat={isPolicyExpenseChat} policyID={policyID} isOdometerDistanceRequest={isOdometerDistanceRequest} - isLoadingReceipt={isStitchingReceipt} + isLoadingReceipt={isStitchingReceipt || (isOdometerDistanceRequest && !hasVerifiedBlobs)} isPerDiemRequest={isPerDiemRequest} shouldShowSmartScanFields={shouldShowSmartScanFields} action={action}