You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$250] Expenses - Receipt images that were loaded previously are not displayed in offline mode #83927
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:
Login to staging.new.expensify.com
Open the chat with expenses with receipts images
Make sure they are loaded
Go back to inbox/any other page
Turn off the internet connection (for real, not simulation)
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
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:
Receipt URL (e.g., /receipts/w_xxx.jpg) stored in transaction.receipt.source in Onyx
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:
On fetch success: store the blob URL in the cache map (keyed by the source URI) in addition to React state
On component remount: check the cache map first — if a blob URL exists for this URI, use it immediately without re-fetching
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)
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.
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.
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.
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
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.
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.
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.
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.
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:
Create a module-level Map<string, string> (URI → blob URL) at the top of Image/index.tsx
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.
On fetch failure (offline), fall back to the cached blob URL if available.
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)
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.
Service Worker — Transparent but adds infrastructure complexity, harder to implement per-image LRU, and Service Worker registration has platform quirks.
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.
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:
constCACHE_NAME="receipt-image-cache";asyncfunctionfetchWithCache(uri: string,headers: Record<string,string>,): Promise<string>{try{// Network-first: try fetching, cache response clone on successconstresponse=awaitfetch(uri,{ headers,cache: "no-store"});if(response.ok){constcache=awaitcaches.open(CACHE_NAME);awaitcache.put(uri,response.clone());returnURL.createObjectURL(awaitresponse.blob());}thrownewError(`HTTP ${response.status}`);}catch{// Offline fallback: serve from Cache APIconstcache=awaitcaches.open(CACHE_NAME);constcached=awaitcache.match(uri);if(cached){returnURL.createObjectURL(awaitcached.blob());}thrownewError("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
What alternative solutions did you explore? (Optional)
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.
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.
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.
📣 @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:
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.
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.
Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
📣 @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:
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.
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.
Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
Activity
melvin-bot commentedon Mar 2, 2026
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.MelvinBot commentedon Mar 2, 2026
🔍 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). WhenisAuthTokenRequiredis true, theImagecomponent (src/components/Image/index.tsx:122-130) adds this header to the image source. The presence ofheaderstriggers expo-image's webuseHeadershook (node_modules/expo-image/src/web/hooks.ts:36-76), which usesfetch()with the auth header, converts the response to a blob, and creates a temporaryblob:object URL stored in ReactuseState.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,
objectURLinitializes tonull, anduseHeadersfires a newfetch(). If the user is offline, the fetch fails,objectURLstaysnull, andImageWrapper(node_modules/expo-image/src/web/ImageWrapper.tsx:62-64) returnsnull— rendering nothing.The
cacheKeyproperty 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 viacache: 'default'on thefetch()call, which depends entirely on the server'sCache-Controlresponse headers. If the Expensify receipt endpoint returnsno-storeor short-lived cache headers, the browser HTTP cache cannot serve the images offline either.Data flow:
/receipts/w_xxx.jpg) stored intransaction.receipt.sourcein OnyxgetThumbnailAndImageURIs(src/libs/ReceiptUtils.ts:56-63) appends.1024.jpgfor thumbnailsReportActionItemImage(src/components/ReportActionItem/ReportActionItemImage.tsx:145-155) setsisAuthTokenRequired: trueImagecomponent addsX-Chat-Attachment-Tokenheader andcacheKeyto sourceuseHeadershook on web →fetch()→ blob →blob:URL inuseStatefetch()fails →objectURL= null → image invisibleSecondary factor for long sessions (>2hrs): The
Imagecomponent also checksisExpiredSession(session.creationDate)at line 123. If the session is expired,sourceis set toundefinedand aLoadingIndicatoris shown instead (line 159-166). TheAttachmentImageReauthenticator(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. WhenuseHeadersequivalent logic runs:This should be implemented in the App's
Imagecomponent (or a web-specific wrapper around it), since modifying expo-image directly would require an upstream PR. TheImagecomponent atsrc/components/Image/index.tsxalready 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-Controlheaders (e.g.,Cache-Control: private, max-age=86400) so the browser's built-in HTTP cache can serve them offline via the existingcache: 'default'fetch option.What alternative solutions did you explore? (Optional)
useHeadershook 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 thisto create a draft PR, or@MelvinBot <your feedback>to refine this analysis.melvin-bot commentedon Mar 2, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @Ollyws (
External)melvin-bot commentedon Mar 2, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022028553696627432421
[-]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[/+]daledah commentedon Mar 2, 2026
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 whenisAuthTokenRequiredis true.App/src/components/Image/index.tsx
Lines 122 to 130 in 39e8000
ReportActionItemImage sets
isAuthTokenRequired: truefor receipt thumbnails. The source (with headers) is passed to expo-image, which uses auseHeadershook that fetches the image with the auth header, converts the response to a blob, and creates a temporaryblob:object URL stored in ReactuseState. When the user navigates away, the component unmounts and the state is destroyed. When the user returns offline, the component remounts,objectURLis null, and a new fetch fails—so the image does not render.App/src/components/ReportActionItem/ReportActionItemImage.tsx
Lines 145 to 155 in 39e8000
What changes do you think we should make in order to solve the problem?
Patch expo-image's
useHeadershook 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.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.
openclawdwang-ship-it commentedon Mar 2, 2026
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
headersin the source object (Image/index.tsx:124-129). When expo-image sees headers, its internaluseHeadershook fetches the image, creates ablob:URL, and stores it in ReactuseState. When the component unmounts (user navigates away), the state is destroyed. On remount while offline, the fetch fails and the image renders nothing.The
cacheKeyproperty 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 anynode_modulespatches.Approach: Before passing the source to
BaseImage, pre-fetch authenticated images ourselves and pass a cachedblob:URL without headers. This bypasses expo-image'suseHeadersentirely.Implementation:
Map<string, string>(URI → blob URL) at the top ofImage/index.tsxuseEffectin theImagecomponent: 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.Inside the
Imagecomponent, a newuseEffectwould:headers, checkgetCachedBlobUrl(source.uri){uri: cachedBlobUrl}(no headers → expo-image uses it directly, nouseHeaders)fetch(source.uri, {headers})→ blob →URL.createObjectURL()→setCachedBlobUrl()→ set stateThe source passed to
BaseImagewould be the resolved blob URL, so expo-image never sees headers on web and never invokes its brokenuseHeadershook.What alternative solutions did you explore? (Optional)
useHeadershook — Works but couples to expo-image internals, adds maintenance burden when upgrading, and the cache logic doesn't belong in a generic image library.afnanahmadtariq commentedon Mar 2, 2026
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
Imagecomponent (src/components/Image/index.tsx, lines 122–130) adds anX-Chat-Attachment-Tokenheader for authenticated images. This triggers expo-image'suseHeadershook, which callsfetch(), converts the response to a blob, and stores ablob:URL in ReactuseState.The blob URL only exists in component state. Navigate away → component unmounts → state destroyed. Navigate back offline →
fetch()fails → no image.The
cacheKeyproperty 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
Mapcaches, 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:A no-op
ImageCacheWeb.tsfor native (where caching is already handled by SDWebImage/Glide viacacheKey).2. In
src/components/Image/index.tsx, for web +isAuthTokenRequiredimages: pre-fetch throughfetchWithCache()in auseEffect, store the resultingblob:URL in state, and pass it as a plain source (no headers) toBaseImage— bypassing expo-image'suseHeadersentirely. Revoke the blob URL in theuseEffectcleanup.This gives us:
patch-packagemaintenance.web.ts) keeps native caching unchangedWhat alternative solutions did you explore? (Optional)
Mapcache — 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.useHeadershook — Fragile; breaks on expo-image updates, requirespatch-package, and still uses an in-memory Map that doesn't persist.melvin-bot commentedon Mar 2, 2026
📣 @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:
Format:
melvin-bot commentedon Mar 2, 2026
📣 @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:
Format:
afnanahmadtariq commentedon Mar 2, 2026
Contributor details
Your Expensify account email: afnanahmedtariq@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/afnanahmadtariq
melvin-bot commentedon Mar 2, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
openclawdwang-ship-it commentedon Mar 2, 2026
Contributor details
Your Expensify account email: openclawdwang@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~011ba61d97a61950c5
melvin-bot commentedon Mar 2, 2026
openclawdwang-ship-it commentedon Mar 3, 2026
Contributor details
Your Expensify account email: openclawdwang@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~011ba61d97a61950c5
melvin-bot commentedon Mar 3, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
VickyStash commentedon Mar 3, 2026
I think this issues is the same as #83112 cc @Julesssss
Julesssss commentedon Mar 3, 2026
Closing as dupe of #83112