From a6d4e8cdd9f5366d6c28f13d3d6fe0a82b4ba4bd Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 17 Sep 2026 15:56:18 +0700 Subject: [PATCH 1/2] fix: Receipt preview is blank in expense details page --- .../MoneyRequestReceiptView.tsx | 3 +- .../ReportActionItemImage.tsx | 2 + src/libs/ReceiptStorage/index.native.ts | 4 +- src/libs/ReceiptStorage/index.ts | 36 +++++++++++++++++- src/libs/ReceiptStorage/types.ts | 6 +++ src/libs/actions/IOU/MoneyRequestBuilder.ts | 5 ++- tests/unit/libs/receiptStorageTest.ts | 37 +++++++++++++++++++ 7 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 6fbbab93b057..329dab146723 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -38,6 +38,7 @@ import {getMicroSecondOnyxErrorObject, getMicroSecondOnyxErrorWithTranslationKey import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {isGroupPolicyByType} from '@libs/PolicyUtils'; +import ReceiptStorage from '@libs/ReceiptStorage'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {getOriginalMessage, isMoneyRequestAction, wasActionTakenByCurrentUser} from '@libs/ReportActionsUtils'; import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils'; @@ -222,7 +223,7 @@ function MoneyRequestReceiptView({ } }, [isLoading, hoverBind]); - const displayedReceiptSource = transaction?.receipt?.localSource ?? transaction?.receipt?.source; + const displayedReceiptSource = ReceiptStorage.resolve(transaction?.receipt?.localSource) ?? transaction?.receipt?.source; const prevDisplayedReceiptSource = usePrevious(displayedReceiptSource); useEffect(() => { diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 8e73d9a89a98..97bf88d70d18 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -208,6 +208,8 @@ function ReportActionItemImage({ shouldUseThumbnailImage: shouldUseThumbnailImage ?? true, isAuthTokenRequired: false, source: shouldUseThumbnailImage ? (effectiveThumbnail ?? effectiveImage ?? '') : originalImageSource, + fallbackIcon: icons.Receipt, + fallbackIconSize: isSingleImage ? variables.iconSizeSuperLarge : variables.iconSizeExtraLarge, // If the image is full height, use initial position to make sure it will grow properly to fill the container shouldUseInitialObjectPosition: isMapDistanceRequest && !shouldUseFullHeight, diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index da95c83a91c8..f735b5bebfd4 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -52,6 +52,8 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => { const toLocalUri: ReceiptStorage['toLocalUri'] = (durableName) => `file://${getReceiptsUploadFolderPath()}/${durableName}`; +const retain: ReceiptStorage['retain'] = () => {}; + const resolve: ReceiptStorage['resolve'] = (source) => { if (typeof source !== 'string') { return undefined; @@ -63,6 +65,6 @@ const resolve: ReceiptStorage['resolve'] = (source) => { return durableName ? toLocalUri(durableName) : source; }; -const receiptStorage: ReceiptStorage = {adopt, toLocalUri, resolve}; +const receiptStorage: ReceiptStorage = {adopt, toLocalUri, retain, resolve}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index caa760269fa3..b65adb6cea9b 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -1,10 +1,42 @@ import type ReceiptStorage from './types'; -/** Web has no filesystem to move receipts into, and a blob URL already lives as long as the document. */ +/** + * Web has no filesystem to move receipts into. A blob:/file: object URL is only valid for this + * document — Onyx may still restore the string after a reload, but the Blob is gone. Track the + * URIs this document created so resolve() can refuse stale ones and callers fall back to the + * server receipt. + * + * Prefer a blob:/file: prefix check over isLocalFile(): that helper also matches root-relative + * remote URLs like `/staging/chat-attachments/...`. + */ +const sessionLocalSources = new Set(); + +function isSessionLocalUri(source: string): boolean { + return source.startsWith('blob:') || source.startsWith('file:'); +} + +const retain: ReceiptStorage['retain'] = (source) => { + if (!isSessionLocalUri(source)) { + return; + } + sessionLocalSources.add(source); +}; + +const resolve: ReceiptStorage['resolve'] = (source) => { + if (typeof source !== 'string') { + return undefined; + } + if (isSessionLocalUri(source)) { + return sessionLocalSources.has(source) ? source : undefined; + } + return source; +}; + const receiptStorage: ReceiptStorage = { adopt: (uriOrPath) => Promise.resolve(uriOrPath), toLocalUri: (durableName) => durableName, - resolve: (source) => (typeof source === 'string' ? source : undefined), + retain, + resolve, }; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index 217fbadca8b0..affc64c59a53 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -8,6 +8,12 @@ type ReceiptStorage = { /** Valid for this launch only, so never store the result. */ toLocalUri: (durableName: string) => string; + /** + * Claims a local source that is about to be stored in Onyx so resolve() will accept it this session. + * No-op on native (files are durable). On web, object URLs die with the document. + */ + retain: (source: string) => void; + /** Re-roots a stored source onto the current folder. A remote source passes through unchanged. */ resolve: (source: ReceiptSource | null | undefined) => string | undefined; }; diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index e8e360ee95db..af638d8a9e8b 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -12,6 +12,7 @@ import {buildOptimisticNextStep} from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getDistanceRateCustomUnit, hasDependentTags, isGroupPolicy} from '@libs/PolicyUtils'; +import ReceiptStorage from '@libs/ReceiptStorage'; import {getOriginalMessage, getReportActionHtml, getReportActionText, isReportPreviewAction} from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; import { @@ -444,7 +445,9 @@ function buildOnyxDataForTestDriveIOU( function getTransactionWithPreservedLocalReceiptSource(transaction: OnyxTypes.Transaction, isScanRequest: boolean): OnyxTypes.Transaction { if (isScanRequest && isLocalFile(transaction.receipt?.source)) { - return {...transaction, receipt: {...transaction.receipt, localSource: String(transaction.receipt?.source)}}; + const localSource = String(transaction.receipt?.source); + ReceiptStorage.retain(localSource); + return {...transaction, receipt: {...transaction.receipt, localSource}}; } return transaction; } diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index b02239c08497..17bd5b431300 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -20,6 +20,7 @@ jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ // Import the native implementation by path. Jest resolves the bare specifier to the web implementation. const {default: ReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts'); +const {default: WebReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.ts'); describe('ReceiptStorage', () => { beforeEach(() => { @@ -103,4 +104,40 @@ describe('ReceiptStorage', () => { expect(ReceiptStorage.resolve('https://www.expensify.com/receipts/w_9.jpg')).toBe('https://www.expensify.com/receipts/w_9.jpg'); }); }); + + describe('retain (native)', () => { + it('is a no-op that does not change resolve behaviour', () => { + const path = `file://${FOLDER}/receipt_9.jpg`; + ReceiptStorage.retain(path); + expect(ReceiptStorage.resolve(path)).toBe(path); + }); + }); +}); + +describe('ReceiptStorage (web)', () => { + it('returns a retained blob: URL from resolve for the rest of the session', () => { + const blobURL = `blob:https://new.expensify.com/${Math.random()}`; + WebReceiptStorage.retain(blobURL); + + expect(WebReceiptStorage.resolve(blobURL)).toBe(blobURL); + }); + + it('returns undefined for a blob: URL that was never retained this session (e.g. restored from Onyx after reload)', () => { + expect(WebReceiptStorage.resolve('blob:https://new.expensify.com/stale-from-previous-document')).toBeUndefined(); + }); + + it('returns undefined for a file: URL that was never retained this session', () => { + expect(WebReceiptStorage.resolve('file:///tmp/stale-receipt.jpg')).toBeUndefined(); + }); + + it('passes remote and root-relative sources through without requiring retain', () => { + expect(WebReceiptStorage.resolve('https://www.expensify.com/receipts/w_9.jpg')).toBe('https://www.expensify.com/receipts/w_9.jpg'); + expect(WebReceiptStorage.resolve('/staging/chat-attachments/abc.jpg')).toBe('/staging/chat-attachments/abc.jpg'); + }); + + it('ignores retain for non-session-local URIs', () => { + const remote = 'https://www.expensify.com/receipts/w_9.jpg'; + WebReceiptStorage.retain(remote); + expect(WebReceiptStorage.resolve(remote)).toBe(remote); + }); }); From cee00f223b73cc24f5993cd7ff3b15b0c9c8f672 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 17 Sep 2026 17:22:49 +0700 Subject: [PATCH 2/2] update comment --- src/libs/ReceiptStorage/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index b65adb6cea9b..8fa98e6d5d15 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -2,7 +2,7 @@ import type ReceiptStorage from './types'; /** * Web has no filesystem to move receipts into. A blob:/file: object URL is only valid for this - * document — Onyx may still restore the string after a reload, but the Blob is gone. Track the + * document. Onyx may still restore the string after a reload, but the Blob is gone. Track the * URIs this document created so resolve() can refuse stale ones and callers fall back to the * server receipt. *