Skip to content

[$250] Expenses - Receipt images that were loaded previously are not displayed in offline mode #83927

Closed

Description

@jponikarchuk

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number: v9.3.27-8
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: #83217
Email or phone of affected tester (no customers): gocemate+b21549011@gmail.com
Issue reported by: Applause Internal Team
Bug source: Pull Request QA execution
Device used: Windows 10/Chrome
App Component: Chat Report View

Action Performed:

  1. Login to staging.new.expensify.com
  2. Open the chat with expenses with receipts images
  3. Make sure they are loaded
  4. Go back to inbox/any other page
  5. Turn off the internet connection (for real, not simulation)
  6. Return to the chat from step 2.

Expected Result:

The receipts images that were loaded previously are displayed.

Actual Result:

Receipt images that were loaded previously are not displayed in offline mode

Workaround:

Unknown

Platforms:

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

Screenshots/Videos

1.mp4

View all open jobs on GitHub

Issue OwnerCurrent Issue Owner: @Ollyws
Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022028553696627432421
  • Upwork Job ID: 2028553696627432421
  • Last Price Increase: 2026-03-02

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Mar 2, 2026
  2. melvin-bot commented on Mar 2, 2026

    @melvin-bot

    Triggered auto assignment to @JmillsExpensify (Bug), see https://stackoverflow.com/c/expensify/questions/14418 for more details. Please add this bug to a GH project, as outlined in the SO.

  3. MelvinBot commented on Mar 2, 2026

    @MelvinBot
    Contributor

    🔍 Issue Analysis

    Please re-state the problem that we are trying to solve in this issue.

    Receipt images that were previously loaded and displayed in a chat become invisible when the user navigates away from the chat, goes offline, and then navigates back to the same chat. The images should be served from cache since they were already downloaded.

    What is the root cause of that problem?

    On web, receipt images require authentication via an HTTP header (X-Chat-Attachment-Token). When isAuthTokenRequired is true, the Image component (src/components/Image/index.tsx:122-130) adds this header to the image source. The presence of headers triggers expo-image's web useHeaders hook (node_modules/expo-image/src/web/hooks.ts:36-76), which uses fetch() with the auth header, converts the response to a blob, and creates a temporary blob: object URL stored in React useState.

    The problem is that this blob URL is ephemeral — it only exists in component state. When the user navigates away, the component unmounts and the state is destroyed. When the user navigates back, the component remounts, objectURL initializes to null, and useHeaders fires a new fetch(). If the user is offline, the fetch fails, objectURL stays null, and ImageWrapper (node_modules/expo-image/src/web/ImageWrapper.tsx:62-64) returns null — rendering nothing.

    The cacheKey property added in PR #83217 only benefits native platforms (SDWebImage on iOS, Glide on Android). On web, expo-image has no equivalent application-level disk cache. The only caching available is the browser's HTTP cache via cache: 'default' on the fetch() call, which depends entirely on the server's Cache-Control response headers. If the Expensify receipt endpoint returns no-store or short-lived cache headers, the browser HTTP cache cannot serve the images offline either.

    Data flow:

    1. Receipt URL (e.g., /receipts/w_xxx.jpg) stored in transaction.receipt.source in Onyx
    2. getThumbnailAndImageURIs (src/libs/ReceiptUtils.ts:56-63) appends .1024.jpg for thumbnails
    3. ReportActionItemImage (src/components/ReportActionItem/ReportActionItemImage.tsx:145-155) sets isAuthTokenRequired: true
    4. Image component adds X-Chat-Attachment-Token header and cacheKey to source
    5. expo-image's useHeaders hook on web → fetch() → blob → blob: URL in useState
    6. Navigate away → component unmounts → blob URL state destroyed
    7. Navigate back offline → re-mount → fetch() fails → objectURL = null → image invisible

    Secondary factor for long sessions (>2hrs): The Image component also checks isExpiredSession(session.creationDate) at line 123. If the session is expired, source is set to undefined and a LoadingIndicator is shown instead (line 159-166). The AttachmentImageReauthenticator (src/libs/actions/Session/AttachmentImageReauthenticator.ts:59) bails out when offline, leaving the image permanently stuck in a loading state.

    What changes do you think we should make in order to solve the problem?

    Implement a web-specific image cache layer that persists blob URLs across component mount/unmount cycles. This could be a module-level Map<string, string> (keyed by URI) that stores blob URLs. When useHeaders equivalent logic runs:

    1. On fetch success: store the blob URL in the cache map (keyed by the source URI) in addition to React state
    2. On component remount: check the cache map first — if a blob URL exists for this URI, use it immediately without re-fetching
    3. On fetch failure (offline): fall back to the cached blob URL if available

    This should be implemented in the App's Image component (or a web-specific wrapper around it), since modifying expo-image directly would require an upstream PR. The Image component at src/components/Image/index.tsx already has platform-specific logic and could maintain a web image cache module.

    Alternatively, a Service Worker approach could intercept fetch() requests matching the receipt URL pattern and cache them using the Cache API, providing transparent offline support without modifying the component code.

    An additional server-side improvement would be to ensure the receipt/attachment endpoints return appropriate Cache-Control headers (e.g., Cache-Control: private, max-age=86400) so the browser's built-in HTTP cache can serve them offline via the existing cache: 'default' fetch option.

    What alternative solutions did you explore? (Optional)

    1. Using the Cache API directly in the Image component — Before passing the source to expo-image, use the Cache API to pre-cache the fetch response. On remount, check Cache API first. This is cleaner but more complex and has browser compatibility considerations.
    2. IndexedDB storage — Store image blobs in IndexedDB for true persistence across page reloads. More robust than in-memory cache but adds complexity and storage management concerns.
    3. Upstream expo-image fix — Modify the useHeaders hook to maintain a module-level cache. This would benefit all expo-image users on web but requires an upstream contribution to the expo project.

    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, or @MelvinBot <your feedback> to refine this analysis.

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Mar 2, 2026
  5. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Mar 2, 2026
  6. melvin-bot commented on Mar 2, 2026

    @melvin-bot

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

  7. melvin-bot commented on Mar 2, 2026

    @melvin-bot
  8. changed the title [-]Expenses - Receipt images that were loaded previously are not displayed in offline mode[/-] [+][$250] Expenses - Receipt images that were loaded previously are not displayed in offline mode[/+] on Mar 2, 2026
  9. daledah commented on Mar 2, 2026

    @daledah
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Receipt images that were loaded previously are not displayed in offline mode.

    What is the root cause of that problem?

    On web, receipt images require authentication via an HTTP header (X-Chat-Attachment-Token). The Image component adds this header to the source when isAuthTokenRequired is true.

    if (isAuthTokenRequired && authToken) {
    if (!!session?.creationDate && !isExpiredSession(session.creationDate)) {
    return {
    ...propsSource,
    cacheKey: propsSource.uri,
    headers: {
    [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken,
    },
    };

    ReportActionItemImage sets isAuthTokenRequired: true for receipt thumbnails. The source (with headers) is passed to expo-image, which uses a useHeaders hook that fetches the image with the auth header, converts the response to a blob, and creates a temporary blob: object URL stored in React useState. When the user navigates away, the component unmounts and the state is destroyed. When the user returns offline, the component remounts, objectURL is null, and a new fetch fails—so the image does not render.

    } else if (thumbnail && !isLocalFile) {
    propsObj = {
    shouldUseThumbnailImage: shouldUseThumbnailImage ?? true,
    // PDF won't have originalImage that we can use. Use thumbnail instead
    // We explicitly want to use || instead of nullish-coalescing because shouldUseThumbnailImage can be false.
    // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
    source: shouldUseThumbnailImage || isPDF ? thumbnailSource : originalImageSource,
    fallbackIcon: icons.Receipt,
    fallbackIconSize: isSingleImage ? variables.iconSizeSuperLarge : variables.iconSizeExtraLarge,
    isAuthTokenRequired: true,

    What changes do you think we should make in order to solve the problem?

    Patch expo-image's useHeaders hook to add a module-level cache for blob URLs, keyed by source URI. On fetch success, store the blob URL in the cache. On component remount or fetch failure (offline), return the cached blob URL if available. Use LRU eviction (max 100 entries) to prevent memory growth.

    diff --git a/node_modules/expo-image/src/web/hooks.ts b/node_modules/expo-image/src/web/hooks.ts
    index 1234567..abcdefg 100644
    --- a/node_modules/expo-image/src/web/hooks.ts
    +++ b/node_modules/expo-image/src/web/hooks.ts
    @@ -34,6 +34,20 @@ export function useImageHashes(source: ImageSource | null | undefined) {
       }, [blurhash, thumbhash, isThumbhashString, isBlurhashString, source]);
     }
     
    +const MAX_CACHE_SIZE = 100;
    +const blobUrlCache = new Map<string, string>();
    +const cacheKeys: string[] = [];
    +
    +function getCacheKey(uri: string, headers: Record<string, string>): string {
    +  return uri;
    +}
    +
    +function setCachedBlobUrl(key: string, blobUrl: string): void {
    +  if (blobUrlCache.size >= MAX_CACHE_SIZE && !blobUrlCache.has(key)) {
    +    const oldest = cacheKeys.shift();
    +    if (oldest) blobUrlCache.delete(oldest);
    +  }
    +  blobUrlCache.set(key, blobUrl);
    +  if (!cacheKeys.includes(key)) cacheKeys.push(key);
    +}
    +
     export function useHeaders(
       source: ImageSource | null | undefined,
       cachePolicy: ImageNativeProps['cachePolicy'],
       onError?: OnErrorEvent[]
     ): ImageSource | null | undefined {
      const [objectURL, setObjectURL] = useState<string | null>(null);
      useEffect(() => {
        (async () => {
          if (!source?.headers || !source.uri) {
            return;
          }
    +     const cacheKey = getCacheKey(source.uri, source.headers);
    +     const cached = blobUrlCache.get(cacheKey);
    +     if (cached) {
    +       setObjectURL(cached);
    +       return;
    +     }
          try {
            const result = await fetch(source.uri, {
              headers: source.headers,
              cache: cachePolicy === 'none' ? 'no-cache' : 'default',
              redirect: 'follow',
            });
            if (!result.ok) {
              throw new Error(`Failed to fetch image: ${result.status} ${result.statusText}`);
            }
            const blob = await result.blob();
            setObjectURL((prevObjURL) => {
              if (prevObjURL) {
                URL.revokeObjectURL(prevObjURL);
              }
    -         return URL.createObjectURL(blob);
    +         const blobUrl = URL.createObjectURL(blob);
    +         setCachedBlobUrl(cacheKey, blobUrl);
    +         return blobUrl;
             });
           } catch {
    -        onError?.forEach((e) => e?.({ source }));
    +        const fallback = blobUrlCache.get(cacheKey);
    +        if (fallback) setObjectURL(fallback);
    +        else onError?.forEach((e) => e?.({ source }));
           }
        })();
      }, [source]);
      if (!source?.headers) {
        return source;
      }
      if (!objectURL) {
        // Avoid fetching a URL without headers if we have headers
        return null;
      }
      return { ...source, uri: objectURL };
     }

    What alternative solutions did you explore? (Optional)

    N/A

    Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.

  10. openclawdwang-ship-it commented on Mar 2, 2026

    @openclawdwang-ship-it

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Receipt images that were previously loaded become invisible when the user navigates away and returns while offline.

    What is the root cause of that problem?

    On web, authenticated images use headers in the source object (Image/index.tsx:124-129). When expo-image sees headers, its internal useHeaders hook fetches the image, creates a blob: URL, and stores it in React useState. When the component unmounts (user navigates away), the state is destroyed. On remount while offline, the fetch fails and the image renders nothing.

    The cacheKey property set at line 126 only benefits native platforms (SDWebImage/Glide), not web.

    What changes do you think we should make in order to solve the problem?

    Add a web image cache at the app level in src/components/Image/index.tsx, avoiding any node_modules patches.

    Approach: Before passing the source to BaseImage, pre-fetch authenticated images ourselves and pass a cached blob: URL without headers. This bypasses expo-image's useHeaders entirely.

    Implementation:

    1. Create a module-level Map<string, string> (URI → blob URL) at the top of Image/index.tsx
    2. Add a useEffect in the Image component: when the source has headers, check the cache. If hit, use the cached blob URL (no headers). If miss, fetch with the auth header, create a blob URL, store it in the cache, and set it as the source.
    3. On fetch failure (offline), fall back to the cached blob URL if available.
    4. Add LRU eviction (max ~100 entries) to prevent unbounded memory growth.
    // Module-level cache (survives unmount/remount)
    const blobCache = new Map<string, string>();
    const cacheOrder: string[] = [];
    const MAX_CACHE = 100;
    
    function getCachedBlobUrl(uri: string): string | undefined {
        return blobCache.get(uri);
    }
    
    function setCachedBlobUrl(uri: string, blobUrl: string) {
        if (blobCache.size >= MAX_CACHE && \!blobCache.has(uri)) {
            const oldest = cacheOrder.shift();
            if (oldest) {
                URL.revokeObjectURL(blobCache.get(oldest)\!);
                blobCache.delete(oldest);
            }
        }
        blobCache.set(uri, blobUrl);
        const idx = cacheOrder.indexOf(uri);
        if (idx \!== -1) cacheOrder.splice(idx, 1);
        cacheOrder.push(uri);
    }

    Inside the Image component, a new useEffect would:

    • When source has headers, check getCachedBlobUrl(source.uri)
    • If cached: set local state to {uri: cachedBlobUrl} (no headers → expo-image uses it directly, no useHeaders)
    • If not cached: fetch(source.uri, {headers}) → blob → URL.createObjectURL() → setCachedBlobUrl() → set state
    • On fetch error: fall back to cached URL if available

    The source passed to BaseImage would be the resolved blob URL, so expo-image never sees headers on web and never invokes its broken useHeaders hook.

    What alternative solutions did you explore? (Optional)

    1. Patching expo-image's useHeaders hook — Works but couples to expo-image internals, adds maintenance burden when upgrading, and the cache logic doesn't belong in a generic image library.
    2. Service Worker — Transparent but adds infrastructure complexity, harder to implement per-image LRU, and Service Worker registration has platform quirks.
    3. Cache API directly — More persistent than in-memory Map (survives page reload) but adds async complexity for a scenario that primarily affects navigation within a single session.
  11. afnanahmadtariq commented on Mar 2, 2026

    @afnanahmadtariq

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    Receipt images that were previously loaded in a chat become invisible when the user navigates away, goes offline, and navigates back.

    What is the root cause of that problem?

    On web, the Image component (src/components/Image/index.tsx, lines 122–130) adds an X-Chat-Attachment-Token header for authenticated images. This triggers expo-image's useHeaders hook, which calls fetch(), converts the response to a blob, and stores a blob: URL in React useState.

    The blob URL only exists in component state. Navigate away → component unmounts → state destroyed. Navigate back offline → fetch() fails → no image.

    The cacheKey property only benefits native platforms (SDWebImage/Glide). On web, expo-image has no application-level disk cache.

    What changes do you think we should make in order to solve the problem?

    Use the browser's Cache API to persistently cache authenticated image responses on web. Unlike in-memory Map caches, the Cache API persists across page reloads and tab closures, is managed by the browser (automatic eviction), and has no blob URL memory leak concerns.

    1. Create a web-specific module src/libs/ImageCacheWeb.web.ts:

    const CACHE_NAME = "receipt-image-cache";
    
    async function fetchWithCache(
      uri: string,
      headers: Record<string, string>,
    ): Promise<string> {
      try {
        // Network-first: try fetching, cache response clone on success
        const response = await fetch(uri, { headers, cache: "no-store" });
        if (response.ok) {
          const cache = await caches.open(CACHE_NAME);
          await cache.put(uri, response.clone());
          return URL.createObjectURL(await response.blob());
        }
        throw new Error(`HTTP ${response.status}`);
      } catch {
        // Offline fallback: serve from Cache API
        const cache = await caches.open(CACHE_NAME);
        const cached = await cache.match(uri);
        if (cached) {
          return URL.createObjectURL(await cached.blob());
        }
        throw new Error("Image not available offline");
      }
    }
    export { fetchWithCache };

    A no-op ImageCacheWeb.ts for native (where caching is already handled by SDWebImage/Glide via cacheKey).

    2. In src/components/Image/index.tsx, for web + isAuthTokenRequired images: pre-fetch through fetchWithCache() in a useEffect, store the resulting blob: URL in state, and pass it as a plain source (no headers) to BaseImage — bypassing expo-image's useHeaders entirely. Revoke the blob URL in the useEffect cleanup.

    This gives us:

    • Persistent offline support — survives page reloads, not just "navigate away & back"
    • No expo-image patching — no patch-package maintenance
    • Browser-managed storage — no custom LRU logic needed
    • Clean memory — blob URLs created on demand, revoked on unmount
    • Native platforms unaffected — platform extension (.web.ts) keeps native caching unchanged

    What alternative solutions did you explore? (Optional)

    1. In-memory Map cache — Simpler, but lost on page refresh — doesn't truly solve offline if the user reloads. Also risks blob URL memory leaks since stored URLs are never revoked.
    2. Patching expo-image's useHeaders hook — Fragile; breaks on expo-image updates, requires patch-package, and still uses an in-memory Map that doesn't persist.
    3. Service Worker — Powerful but over-engineered for this use case. The app doesn't currently use Service Workers for caching, and adding one solely for images introduces lifecycle complexity.
  12. melvin-bot commented on Mar 2, 2026

    @melvin-bot

    📣 @openclawdwang-ship-it! 📣
    Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
    Please follow these steps:

    1. Make sure you've read and understood the contributing guidelines.
    2. Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
    3. Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
    4. Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
      Screen Shot 2022-11-16 at 4 42 54 PM
      Format:
    Contributor details
    Your Expensify account email: <REPLACE EMAIL HERE>
    Upwork Profile Link: <REPLACE LINK HERE>
    
  13. melvin-bot commented on Mar 2, 2026

    @melvin-bot

    📣 @afnanahmadtariq! 📣
    Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
    Please follow these steps:

    1. Make sure you've read and understood the contributing guidelines.
    2. Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
    3. Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
    4. Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
      Screen Shot 2022-11-16 at 4 42 54 PM
      Format:
    Contributor details
    Your Expensify account email: <REPLACE EMAIL HERE>
    Upwork Profile Link: <REPLACE LINK HERE>
    
  14. afnanahmadtariq commented on Mar 2, 2026

    @afnanahmadtariq

    Contributor details
    Your Expensify account email: afnanahmedtariq@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/afnanahmadtariq

  15. melvin-bot commented on Mar 2, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  16. openclawdwang-ship-it commented on Mar 2, 2026

    @openclawdwang-ship-it

    Contributor details
    Your Expensify account email: openclawdwang@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~011ba61d97a61950c5

  17. melvin-bot commented on Mar 2, 2026

    @melvin-bot

    ⚠️ Invalid email. Please make sure to create an Expensify account with this email first here.

  18. openclawdwang-ship-it commented on Mar 3, 2026

    @openclawdwang-ship-it

    Contributor details
    Your Expensify account email: openclawdwang@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~011ba61d97a61950c5

  19. melvin-bot commented on Mar 3, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  20. VickyStash commented on Mar 3, 2026

    @VickyStash
    Contributor

    I think this issues is the same as #83112 cc @Julesssss

  21. Julesssss commented on Mar 3, 2026

    @Julesssss
    Contributor

    Closing as dupe of #83112

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorHelp WantedApply this label when an issue is open to proposals by contributors

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions