Skip to content

Commit a29af33

Browse files
authored
Merge pull request Expensify#88702 from software-mansion-labs/jakubkalinski0/App_stuck_on_E_screen_after_refresh_on_confirm_odometer_page
[Odometer] App stuck on E screen after refresh on confirm odometer page
2 parents 626a3df + b7870ac commit a29af33

4 files changed

Lines changed: 66 additions & 81 deletions

File tree

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
// On native blob:// URLs don't exist, so there is nothing to check
1+
// On native blob:// URLs don't exist, so there is nothing to check —
2+
// callers can always proceed with blob-dependent side-effects (like stitching)
23

3-
const useRestartOnOdometerImagesFailure = () => {};
4+
const useRestartOnOdometerImagesFailure = (): {hasVerifiedBlobs: boolean} => ({hasVerifiedBlobs: true});
45

56
export default useRestartOnOdometerImagesFailure;

src/hooks/useRestartOnOdometerImagesFailure/index.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useEffect, useRef} from 'react';
1+
import {useEffect, useRef, useState} from 'react';
22
import type {OnyxEntry} from 'react-native-onyx';
33
import useOnyx from '@hooks/useOnyx';
44
import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt';
@@ -17,9 +17,27 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
1717
// This is because until the request is saved, the image files are only stored in the browser's memory as blob:// URLs
1818
// and if the browser is refreshed, then the images cease to exist.
1919
// The best way for the user to recover from this is to start over from the start of the request process.
20-
const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry<Transaction>, reportID: string, iouType: IOUType, backToReport: string | undefined, onBackupHandled?: () => void) => {
20+
// Returns `hasVerifiedBlobs` so callers can gate dependent side-effects (e.g. odometer image stitching)
21+
// until this check has confirmed the blobs are still readable. When there are no blob URLs to verify
22+
// (e.g. native file:// paths or remote URLs), `hasVerifiedBlobs` is `true` as soon as Onyx has loaded.
23+
const useRestartOnOdometerImagesFailure = (
24+
transaction: OnyxEntry<Transaction>,
25+
reportID: string,
26+
iouType: IOUType,
27+
backToReport: string | undefined,
28+
onBackupHandled?: () => void,
29+
): {hasVerifiedBlobs: boolean} => {
2130
const [, draftTransactionsMetadata] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector});
2231
const hasCheckedRef = useRef(false);
32+
const [asyncVerificationPassed, setAsyncVerificationPassed] = useState(false);
33+
34+
const hasBlobUrls = (() => {
35+
if (!transaction) {
36+
return false;
37+
}
38+
const paths = [getOdometerImageUri(transaction.comment?.odometerStartImage), getOdometerImageUri(transaction.comment?.odometerEndImage), transaction.receipt?.source?.toString()];
39+
return paths.some((path) => !!path && path.startsWith('blob:'));
40+
})();
2341

2442
useEffect(() => {
2543
if (!transaction || isLoadingOnyxValue(draftTransactionsMetadata)) {
@@ -76,6 +94,7 @@ const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry<Transaction>,
7694
).then((results) => {
7795
const canBeRead = results.every(Boolean);
7896
if (canBeRead) {
97+
setAsyncVerificationPassed(true);
7998
return;
8099
}
81100

@@ -84,6 +103,11 @@ const useRestartOnOdometerImagesFailure = (transaction: OnyxEntry<Transaction>,
84103
navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport);
85104
});
86105
}, [draftTransactionsMetadata, transaction, iouType, reportID, backToReport, onBackupHandled]);
106+
107+
const isOnyxLoading = isLoadingOnyxValue(draftTransactionsMetadata);
108+
const hasVerifiedBlobs = !!transaction && !isOnyxLoading && (!hasBlobUrls || asyncVerificationPassed);
109+
110+
return {hasVerifiedBlobs};
87111
};
88112

89113
export default useRestartOnOdometerImagesFailure;

src/pages/iou/request/step/IOURequestStepConfirmation.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ function IOURequestStepConfirmation({
240240

241241
const odometerStartImage = transaction?.comment?.odometerStartImage;
242242
const odometerEndImage = transaction?.comment?.odometerEndImage;
243-
useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, backToReport);
243+
const {hasVerifiedBlobs} = useRestartOnOdometerImagesFailure(isOdometerDistanceRequest ? transaction : undefined, reportID, iouType, backToReport);
244244

245245
// Pre-insert Search is only useful for flows whose submit ends in handleNavigateAfterExpenseCreate
246246
// (which navigates to Search). Flows that use dismissModalAndOpenReportInInboxTab (PAY,
@@ -553,9 +553,7 @@ function IOURequestStepConfirmation({
553553
odometerStartImage={odometerStartImage}
554554
odometerEndImage={odometerEndImage}
555555
transaction={transaction}
556-
reportID={reportID}
557-
backToReport={backToReport}
558-
iouType={iouType}
556+
hasVerifiedBlobs={hasVerifiedBlobs}
559557
onStitchingChange={setIsStitchingReceipt}
560558
onStitchError={setStitchError}
561559
/>
@@ -648,7 +646,7 @@ function IOURequestStepConfirmation({
648646
isPolicyExpenseChat={isPolicyExpenseChat}
649647
policyID={policyID}
650648
isOdometerDistanceRequest={isOdometerDistanceRequest}
651-
isLoadingReceipt={isStitchingReceipt}
649+
isLoadingReceipt={isStitchingReceipt || (isOdometerDistanceRequest && !hasVerifiedBlobs)}
652650
isPerDiemRequest={isPerDiemRequest}
653651
shouldShowSmartScanFields={shouldShowSmartScanFields}
654652
action={action}

src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx

Lines changed: 34 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@ import {useIsFocused} from '@react-navigation/native';
22
import {useEffect, useRef} from 'react';
33
import type {OnyxEntry} from 'react-native-onyx';
44
import useLocalize from '@hooks/useLocalize';
5-
import clearOdometerDraftTransactionState from '@libs/actions/OdometerTransactionUtils';
6-
import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
75
import Log from '@libs/Log';
86
import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils';
97
import stitchOdometerImages from '@libs/stitchOdometerImages';
108
import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans';
11-
import {checkIfLocalFileIsAccessible, setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
9+
import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
1210
import CONST from '@src/CONST';
13-
import type {IOUType} from '@src/CONST';
1411
import type {Transaction} from '@src/types/onyx';
1512
import type {FileObject} from '@src/types/utils/Attachment';
1613

@@ -19,9 +16,7 @@ type OdometerReceiptStitcherProps = {
1916
odometerStartImage: FileObject | string | null | undefined;
2017
odometerEndImage: FileObject | string | null | undefined;
2118
transaction: OnyxEntry<Transaction>;
22-
reportID: string;
23-
backToReport: string | undefined;
24-
iouType: IOUType;
19+
hasVerifiedBlobs: boolean;
2520
onStitchingChange: (isStitching: boolean) => void;
2621
onStitchError: (error: string) => void;
2722
};
@@ -37,9 +32,7 @@ function OdometerReceiptStitcher({
3732
odometerStartImage,
3833
odometerEndImage,
3934
transaction,
40-
reportID,
41-
backToReport,
42-
iouType,
35+
hasVerifiedBlobs,
4336
onStitchingChange,
4437
onStitchError,
4538
}: OdometerReceiptStitcherProps) {
@@ -51,7 +44,10 @@ function OdometerReceiptStitcher({
5144
} | null>(null);
5245

5346
useEffect(() => {
54-
if (!isOdometerDistanceRequest || !isFocused || !transaction) {
47+
// Wait until useRestartOnOdometerImagesFailure has confirmed the blob URLs are still
48+
// readable. Stitching a dead blob after a browser refresh would race with that hook's
49+
// redirect and leave the UI stuck on the E screen
50+
if (!isOdometerDistanceRequest || !isFocused || !transaction || !hasVerifiedBlobs) {
5551
return;
5652
}
5753

@@ -83,76 +79,42 @@ function OdometerReceiptStitcher({
8379
onStitchingChange(true);
8480
onStitchError('');
8581

86-
const runStitch = () => {
87-
startSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, {
88-
name: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH,
89-
op: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH,
90-
});
82+
startSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH, {
83+
name: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH,
84+
op: CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH,
85+
});
9186

92-
stitchOdometerImages(odometerStartImage, odometerEndImage)
93-
.then((stitchedImage) => {
94-
if (ignore || !stitchedImage) {
95-
cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
96-
return;
97-
}
98-
setMoneyRequestReceipt(transaction.transactionID, getOdometerImageUri(stitchedImage), getOdometerImageName(stitchedImage), true, getOdometerImageType(stitchedImage));
99-
lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage};
100-
endSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
101-
})
102-
.catch((error: unknown) => {
87+
stitchOdometerImages(odometerStartImage, odometerEndImage)
88+
.then((stitchedImage) => {
89+
if (ignore || !stitchedImage) {
10390
cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
104-
if (ignore) {
105-
return;
106-
}
107-
Log.warn('stitchOdometerImages failed', {error});
108-
onStitchError(translate('iou.error.stitchOdometerImagesFailed'));
109-
})
110-
.finally(() => {
111-
if (ignore) {
112-
return;
113-
}
114-
onStitchingChange(false);
115-
});
116-
};
117-
118-
// Pre-flight: verify blob URLs haven't expired before attempting to stitch.
119-
const localImages = [
120-
{uri: startUri, image: odometerStartImage},
121-
{uri: endUri, image: odometerEndImage},
122-
].filter((item): item is {uri: string; image: typeof odometerStartImage} => !!item.uri && item.uri.startsWith('blob:'));
123-
124-
let hasExpiredImages = false;
125-
Promise.all(
126-
localImages.map(({uri, image}) =>
127-
checkIfLocalFileIsAccessible(
128-
getOdometerImageName(image),
129-
uri,
130-
typeof image === 'object' ? image?.type : undefined,
131-
() => {},
132-
() => {
133-
hasExpiredImages = true;
134-
},
135-
),
136-
),
137-
).then(() => {
138-
if (ignore) {
139-
return;
140-
}
141-
if (hasExpiredImages) {
91+
return;
92+
}
93+
setMoneyRequestReceipt(transaction.transactionID, getOdometerImageUri(stitchedImage), getOdometerImageName(stitchedImage), true, getOdometerImageType(stitchedImage));
94+
lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage};
95+
endSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
96+
})
97+
.catch((error: unknown) => {
98+
cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
99+
if (ignore) {
100+
return;
101+
}
102+
Log.warn('stitchOdometerImages failed', {error});
103+
onStitchError(translate('iou.error.stitchOdometerImagesFailed'));
104+
})
105+
.finally(() => {
106+
if (ignore) {
107+
return;
108+
}
142109
onStitchingChange(false);
143-
clearOdometerDraftTransactionState(transaction);
144-
navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport);
145-
return;
146-
}
147-
runStitch();
148-
});
110+
});
149111

150112
return () => {
151113
ignore = true;
152114
onStitchingChange(false);
153115
cancelSpan(CONST.TELEMETRY.SPAN_ODOMETER_IMAGE_STITCH);
154116
};
155-
}, [isOdometerDistanceRequest, isFocused, odometerStartImage, odometerEndImage, transaction, reportID, backToReport, translate, iouType, onStitchingChange, onStitchError]);
117+
}, [isOdometerDistanceRequest, isFocused, odometerStartImage, odometerEndImage, transaction, hasVerifiedBlobs, translate, onStitchingChange, onStitchError]);
156118

157119
return null;
158120
}

0 commit comments

Comments
 (0)