Skip to content

[Due for payment 2026-10-08] Web - Scan - Receipt preview is blank in expense details page #100786

Closed

Description

@applause-bot

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: 9.4.75-0
Reproducible in staging?: Yes
Reproducible in production?: No
If this was caught during regression testing, add the test name, ID and link from BrowserStack: Exploratory
Email or phone of affected tester (no customers): jose.sat94+10jul@gmail.com
Issue reported by: Applause Internal Team
Bug source: Exploratory - Significant User Experience Deterioration
Device used: MacBook Pro/13.7.8
App Component: Other

Action Performed:

  1. Launch Expensify App
  2. Log in with valid credentials
  3. Create expense > Scan > Select any valid receipt
  4. Wait for smartscan finish
  5. Refresh the page
  6. Open expense receipt preview

Expected Result:

Expense details page shows expense receipt preview

Actual Result:

Receipt preview is blank

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome Safari

Screenshots/Videos

Bug7255661_1789004188275.bug.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Julia Ponikarchuk
Bug ID: 7255661

Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    DeployBlockerCashThis issue or pull request should block deployment
    BugSomething is broken. Auto assigns a BugZero manager.
    on Sep 10, 2026
  2. applause-bot commented on Sep 10, 2026

    @applause-bot
    Author

    While Applause doesn't embed attachments for this bug, here are the links for quick view:
    https://platform.applause.com/services/links/v1/external/8872319e55b68933632ec1d1728b4f0c8f077a94d4c7e2f8d6364924d24a8987

  3. melvin-bot commented on Sep 10, 2026

    @melvin-bot

    Triggered auto assignment to @mountiny (DeployBlockerCash), see https://stackoverflowteams.com/c/expensify/questions/9980/ for more details.

  4. melvin-bot commented on Sep 10, 2026

    @melvin-bot

    💬 A slack conversation has been started in #expensify-open-source

  5. github-actions commented on Sep 10, 2026

    @github-actions
    Contributor

    👋 Friendly reminder that deploy blockers are time-sensitive ⏱ issues! Check out the open `StagingDeployCash` deploy checklist to see the list of PRs included in this release, then work quickly to do one of the following:

    1. Identify the pull request that introduced this issue and revert it.
    2. Find someone who can quickly fix the issue.
    3. Fix the issue yourself.
  6. MelvinBot commented on Sep 10, 2026

    @MelvinBot
    Contributor

    🔍 Investigation Summary

    Classification: Frontend bug (App-side; nothing points at the API)
    Causing PR: Not identified with confidence — see ranked candidates below (all Low confidence)
    Related Issues: #100770, #100763, #100761, #100757 (same checklist, same suspect area for the top candidate)

    Recommendation: NEEDS INVESTIGATION

    I traced the exact code path that makes a receipt render blank only after a page refresh on web, but a direct production...staging diff shows every file on that path changed only in comments in this release, so I can't honestly pin a causing PR yet. Keeping DeployBlockerCash and asking for a reproduction with the failing image URL before anything is reverted.

    Labels: No changes. DeployBlockerCash kept (staging-only regression in an App release, so the fix belongs in Expensify/App). DeployBlocker was not present, so nothing to remove.

    📋 Detailed Analysis

    Evidence

    The mechanism that produces a blank receipt after refresh (web only)

    For a Scan request the optimistic transaction stores the local receipt URI a second time under receipt.localSource:

    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)}};
    }
    return transaction;
    }

    isLocalFile() counts blob: as local, so on web localSource is a blob: URL:

    function isLocalFile(receiptUri?: string | number): boolean {
    if (!receiptUri) {
    return false;
    }
    return typeof receiptUri === 'number' || receiptUri?.startsWith('blob:') || receiptUri?.startsWith('file:') || receiptUri?.startsWith('/');
    }

    ReportActionItemImage then prefers localSource over the server-resolved receipt URL, and the preferred value is also what suppresses the server thumbnail:

    const localSource = ReceiptStorage.resolve(transaction?.receipt?.localSource);
    const effectiveIsLocalFile = isLocalFile || !!localSource;
    const effectiveThumbnail = localSource ?? thumbnail;
    const receiptURIs = transaction ? getThumbnailAndImageURIs(transaction, null, null) : undefined;
    const effectivePreviewUri = localSource ? undefined : receiptURIs?.thumbnail320;
    const effectiveImage = localSource != null && typeof image === 'string' ? localSource : image;
    const originalImageSource = tryResolveUrlFromApiRoot(effectiveImage ?? '');
    const thumbnailSource = tryResolveUrlFromApiRoot(effectiveThumbnail ?? '');

    On web ReceiptStorage.resolve is a pass-through — there is no filesystem to adopt the file into, so the blob: URL is handed back verbatim:

    import type ReceiptStorage from './types';
    /** Web has no filesystem to move receipts into, and a blob URL already lives as long as the document. */
    const receiptStorage: ReceiptStorage = {
    adopt: (uriOrPath) => Promise.resolve(uriOrPath),
    toLocalUri: (durableName) => durableName,
    resolve: (source) => (typeof source === 'string' ? source : undefined),
    };
    export default receiptStorage;

    A blob: URL is revoked when the document is destroyed, so after a refresh that URI resolves to nothing and the image renders blank. On native this does not happen because ReceiptStorage (native) adopts the file into a durable receipts folder and re-derives a live file:// path, which is consistent with the issue being reported only for Chrome/Safari desktop.

    MoneyRequestReceiptView also flips its internal isLoading back on whenever receipt.localSource ?? receipt.source changes and only clears it on a successful image load, so a dead source leaves the area empty rather than falling back:

    const displayedReceiptSource = transaction?.receipt?.localSource ?? transaction?.receipt?.source;
    const prevDisplayedReceiptSource = usePrevious(displayedReceiptSource);
    useEffect(() => {
    if (!displayedReceiptSource || prevDisplayedReceiptSource === displayedReceiptSource) {
    return;
    }
    setIsLoading(true);
    }, [displayedReceiptSource, prevDisplayedReceiptSource]);

    Why I still can't name the PR

    This whole path is byte-identical between production and staging apart from comment deletions. I diffed the refs directly rather than trusting the (truncated) compare API:

    • git diff production staging over ReceiptUtils.ts, ReceiptStorage/, ReceiptImage/, ThumbnailImage.tsx, ImageWithSizeCalculation.tsx, Image/, ReportActionItemImage.tsx, MoneyRequestView.tsx, MoneyRequestReceiptView.tsx, EReceiptThumbnail.tsx, tryResolveUrlFromApiRoot.ts → comment-only, plus one unrelated distance-rate guard in MoneyRequestView.tsx.
    • git diff -G'localSource|ReceiptStorage|blob:|isLocalFile' production staging -- src → no matches at all.

    So the trigger is most likely a timing / data-refresh change (something that used to replace the optimistic transaction row with server data before the receipt is first painted after a cold load), not a change to the rendering code itself.

    Verification

    Ruled out by reading the diffs:

    • [No QA] Remove comments that do not add context #100576 "[No QA] Remove comments that do not add context" — 877 files, 5031 deletions, and it touches ReceiptImage/index.tsx, ThumbnailImage.tsx, ReportActionItemImage.tsx, ImageWithSizeCalculation.tsx, EReceiptThumbnail.tsx. I read every one of those patches: they are pure JSDoc deletions plus one /* */ → /** */ conversion. Not the cause.
    • Fix green plus icon alignment in receipt empty state #100249 "Fix green plus icon alignment in receipt empty state" — single line, only changes the + badge size in the empty state; styles.moneyRequestAttachReceiptThumbnailIcon.borderWidth is defined (2), so no NaN size. Not the cause.
    • [No QA] Bump Onyx to 3.0.111 #100681 Onyx 3.0.110 → 3.0.111 — the only diff is a new, explicitly inert OnyxSubscriptionManager. Unlikely.

    Ranked candidates, all Low confidence, listed only as starting points:

    1. refactor: the new-transaction highlight rail and the wide RHP width model #98744 "refactor: the new-transaction highlight rail and the wide RHP width model" by @TaduJR — rewrote useResponsiveLayoutOnWideRHP to a useSyncExternalStore over a mutable store and reworked getVisibleRHPRouteKeys around a seenRouteKeys set. It already looks responsible for several sibling blockers in this checklist ([Due for payment 2026-09-17] Expense - RHP transition animates outside RHP #100770, [Due for payment 2026-09-17] Expense - New expense preview is highlighted every time opening and closing the report #100763, [Due for payment 2026-09-17] Expense - Second expense is highlighted when opening the duplicated report #100761, [Due for payment 2026-09-17] Expense - Expense previews are not highlighted after splitting expense #100757), and the expense details receipt panel is sized off the RHP width model. I could not connect it to a blank image specifically.
    2. [Performance] Pre-mount new expense destinations behind confirmation RHP #97874 "[Performance] Pre-mount new expense destinations behind confirmation RHP" by @JakubKorytko — the one PR here that adds new cold-start behaviour (cleanupPreMountedDraftReports() in src/setup/index.ts) and changes which destination report an expense is submitted into. "Only after refresh" is exactly the window it touches.
    3. [No QA] Bump Onyx to 3.0.111 #100681 / OnyxUpdates gap-detection change (new lastUpdateIDPendingPusherApply in src/libs/actions/OnyxUpdates.ts) — could suppress a reconnect refetch that previously overwrote the transaction row with server data.

    Root Cause

    Underlying defect (independent of which PR exposed it): on web the app persists a blob: object URL into transaction.receipt.localSource and then unconditionally prefers it over the server receipt URL in ReportActionItemImage. blob: URLs do not survive a page reload, so any code path that leaves localSource in Onyx after the reload renders an empty receipt. Web has no durable storage behind ReceiptStorage.resolve, so nothing re-validates the URI before it is used.

    What would confirm the trigger fastest: reproduce on staging, then in the console inspect the transaction (transactions_<transactionID>) after the refresh. If receipt.localSource is still a blob:... URL, the fix is to stop preferring a stale localSource on web (validate it, or clear it once the server returns a receipt source/filename) — a roll-forward, not a revert. If localSource is absent and receipt.source is already the expensify.com/receipts/... URL, then this is a layout/sizing regression and candidate 1 becomes the prime suspect.

  7. MelvinBot commented on Sep 10, 2026

    @MelvinBot
    Contributor

    🤖 Deploy blocker investigation — follow-up

    No causing PR could be pinned. Every file on the receipt-render path is byte-identical between production and staging in this release apart from comment deletions, so this is most likely a timing regression (something that used to replace the optimistic transaction row with server data on cold load), not a change to the rendering code. Full evidence is in the detailed analysis above.

    Most likely mechanism (high confidence, needs one console check to confirm): on web, a Scan expense stores the local blob: URL in transaction.receipt.localSource (MoneyRequestBuilder.ts:432-437), and ReportActionItemImage prefers localSource over the server URL and suppresses the server thumbnail fallback (ReportActionItemImage.tsx:173-181). A blob: URL dies with the document, so after a refresh it resolves to nothing and nothing falls back. Native is unaffected because ReceiptStorage adopts the file into a durable file:// path (ReceiptStorage/index.ts:1-10) — which matches this being reported on desktop Chrome/Safari only.

    One check to settle it. Reproduce, refresh, then in the console:

    Onyx.get(`transactions_<transactionID>`).then(t => console.log(t.receipt));

    If localSource still holds a blob: URL after reload, the fix is to stop unconditionally preferring localSource on web — validate it, or clear it once the server returns a receipt source/filename.

    Revert vs fix: roll forward. There's no clear revert target.

    Automated repro attempt: blocked. I could not reproduce this in the automated web session — attaching a receipt file is out of scope for the browser automation (the Scan tab's Choose files control opens a native file picker the harness can't drive), so the expense could never be created. This needs a human repro or a mobile-path run.

    Candidates considered

    Ruled out by reading the diffs:

    Ranked candidates, all low confidence:

    1. refactor: the new-transaction highlight rail and the wide RHP width model #98744 (wide RHP width model / highlight rail) — already the likely cause of sibling blockers [Due for payment 2026-09-17] Expense - RHP transition animates outside RHP #100770, [Due for payment 2026-09-17] Expense - New expense preview is highlighted every time opening and closing the report #100763, [Due for payment 2026-09-17] Expense - Second expense is highlighted when opening the duplicated report #100761, [Due for payment 2026-09-17] Expense - Expense previews are not highlighted after splitting expense #100757, but I couldn't connect it to a blank image.
    2. [Performance] Pre-mount new expense destinations behind confirmation RHP #97874 (pre-mount expense destinations) — the only PR adding new cold-start behaviour, which is exactly the "only after refresh" window.
    3. The OnyxUpdates gap-detection change (new lastUpdateIDPendingPusherApply) — could suppress a reconnect refetch that previously overwrote the transaction row.

    Diffs run: git diff production staging over ReceiptUtils.ts, ReceiptStorage/, ReceiptImage/, ThumbnailImage.tsx, ImageWithSizeCalculation.tsx, Image/, ReportActionItemImage.tsx, MoneyRequestView.tsx, MoneyRequestReceiptView.tsx, EReceiptThumbnail.tsx, tryResolveUrlFromApiRoot.ts → comment-only. git diff -G'localSource|ReceiptStorage|blob:|isLocalFile' production staging -- src → no matches.


    Next Steps: Reply with @MelvinBot <your feedback> to refine this analysis.


    view run · no recording available

  8. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Sep 10, 2026
  9. melvin-bot commented on Sep 10, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @ahmedGaber93 (External)

  10. 33 remaining items

  11. mountiny commented on Oct 4, 2026

    @mountiny
    Contributor

    I feel like we don't need such regression test

  12. added
    Awaiting PaymentAuto-added when associated PR is deployed to production
    on Oct 4, 2026
  13. melvin-bot commented on Oct 4, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  14. melvin-bot commented on Oct 7, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
    • [BugZero Assignee] I have created a GH issue for creating/updating the regression test once above steps have been agreed upon
  15. mallenexpensify commented on Oct 13, 2026

    @mallenexpensify
    Contributor

    @mkhutornyi who is due what here? I see a lot of mentions of deploy blockers. Thx

  16. mkhutornyi commented on Oct 13, 2026

    @mkhutornyi
    Contributor

    Reproducible in production?: No

    This was false positive. The bug existed in production, not actually deploy blocker.

    Contributor: @nkdengineer, Contributor+: @mkhutornyi

    Our PR didn't cause any regressions.

  17. mallenexpensify commented on Oct 14, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @nkdengineer due $250 via NewDot
    Contributor+: @mkhutornyi due $250 via NewDot

  18. trjExpensify commented on Oct 15, 2026

    @trjExpensify
    Contributor

    approved $250 for @mkhutornyi

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2EngineeringExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions