Repository navigation
[Due for payment 2026-04-30] [$250] iOS - Cached images are not displayed offline #86666
Description
Activity
- addedDailyKSv2KSv2BugSomething is broken. Auto assigns a BugZero manager.Something is broken. Auto assigns a BugZero manager.
on Mar 30, 2026 Proposal
What is the root cause of that problem?
The app has two different image caching strategies for authenticated images (receipts, attachments) — one for web and one for native — and the native one does not provide reliable offline caching.
Web platform (working):
BaseImage.tsxuses theuseCachedImageSourcehook, which explicitly fetches authenticated images with headers, stores the response in the browser's Cache API, and creates blob URLs. This gives reliable offline access because the Cache API is a persistent, dedicated cache the app controls.Native/iOS platform (broken):
BaseImage.native.tsxdoes not useuseCachedImageSource(it uses browser-only APIs:window.caches,URL.createObjectURL). Instead, it passes the image source (with auth headers andcacheKey) directly to expo-image, which delegates to SDWebImage on iOS. The app has no explicit persistent cache layer on native — it relies entirely on SDWebImage's built-in cache, which can evict images based on its own size limits and doesn't guarantee offline persistence.Additionally, the app has a native file-based caching system (
cacheAttachment/getCachedAttachment) but it is disconnected from the rendering pipeline:cacheAttachmentonly runs when the user sends an attachment (not when viewing one), andgetCachedAttachmentis exported but never called by any rendering component.The flow for receipt thumbnails on iOS:
Image/index.tsxwraps the receipt URL withheaders: { X-Chat-Attachment-Token: authToken }andcacheKey: uriBaseImage.native.tsxpasses this directly to<ExpoImage>with no caching hook- expo-image/SDWebImage fetches, displays, and internally caches the image
- When the user goes offline, if SDWebImage has evicted the image from cache, it fails to load →
ThumbnailImageshows the OfflineCloud fallback icon
What changes do you think we should make in order to solve the problem?
Create a native equivalent of
useCachedImageSource(e.g.,useCachedImageSource.native.ts) that:- On first load (online), downloads the authenticated image to a persistent local file using
react-native-blob-util(already a dependency) — similar to howcacheAttachmentworks, but triggered at render/view time rather than upload time - Stores a mapping of
uri → localFilePath(could use Onyx's existingONYXKEYS.COLLECTION.ATTACHMENTor a new in-memory Map with file system backing) - On subsequent renders (including offline), checks for the local cached file first and returns its
file://URI instead of the remote URL - Import and use this hook in
BaseImage.native.tsxthe same wayBaseImage.tsxuses the web version
This mirrors the exact pattern already working on web, adapted for native file system APIs instead of the browser Cache API.
What alternative solutions did you explore? (Optional)
-
Setting
cachePolicy="disk"on expo-image for authenticated images: This would tell SDWebImage to prefer disk cache. However, this doesn't give the app explicit control over cache lifecycle, and SDWebImage can still evict images under memory/storage pressure. It would be an improvement but not a reliable solution. -
Connecting the existing
getCachedAttachmentto the rendering pipeline: The infrastructure (cacheAttachment/getCachedAttachmentinindex.native.ts) partially exists. However, it would need significant rework sincecacheAttachmentcurrently doesn't include auth headers when fetching remote URLs (causing 401 errors for protected images), and it only runs at upload time. Building a new hook is cleaner than retrofitting this system.
Relevant Code
src/components/Image/BaseImage.native.tsx— Native BaseImage, no caching hooksrc/components/Image/BaseImage.tsx— Web BaseImage withuseCachedImageSourcesrc/hooks/useCachedImageSource.ts— Web-only caching hook (uses Cache API)src/components/Image/index.tsx— Adds auth headers + cacheKey (shared)src/libs/actions/Attachment/index.native.ts— Existing (unused) native attachment caching
Next Steps for Contributor+ team: Reply with
@MelvinBot implement thisto create a draft PR,@MelvinBot <your feedback>to refine this analysis, or explain why you are rejecting Melvin's proposal.- addedExternalAdded to denote the issue can be worked on by a contributorAdded to denote the issue can be worked on by a contributor
on Mar 30, 2026 - addedHelp WantedApply this label when an issue is open to proposals by contributorsApply this label when an issue is open to proposals by contributors
on Mar 30, 2026 Triggered auto assignment to Contributor-plus team member for initial proposal review - @mkhutornyi (
External)- changed the title
[-]iOS - Cached images are not displayed offline[/-][+][$250] iOS - Cached images are not displayed offline[/+]on Mar 30, 2026 Job added to Upwork: https://www.upwork.com/jobs/~022038647517986379009
Proposal
Please re-state the problem that we are trying to solve in this issue.
On iOS, previously loaded receipt images in chat are not displayed when the device goes offline. Users see loading indicators instead of cached images, even though expo-image (SDWebImage) has them in its disk cache.
What is the root cause of that problem?
The root cause is in the
Imagewrapper component at https://github.com/Expensify/App/blob/main/src/components/Image/index.tsx#L116-L143. When the session'screationDateis older than 2 hours (SESSION_EXPIRATION_TIME_MS), theisExpiredSessioncheck at line 123 fails, causing the code to fall through to line 132-135 where it callsactivateReauthenticator(session)and returnsundefinedas the image source. Thisundefinedsource causes the component to render aLoadingIndicator(lines 159-165) instead of passing the source to expo-image. Since the reauthenticator bails out when offline (line 68 ofAttachmentImageReauthenticator.ts:if (isOffline || !active) return), no new session ever arrives, and images remain stuck as loading spinners. The expo-image layer with SDWebImage never gets a chance to serve its cached content because it never receives the source with thecacheKey.What changes do you think we should make in order to solve the problem?
We should check the network status in
Image/index.tsxand, when offline, skip the session expiration check and pass the source through with the existing auth token. Expo-image caches bycacheKey(which is set topropsSource.uri), so it will serve the disk-cached image without making a network request.// In src/components/Image/index.tsx import {useNetwork} from '@hooks/useNetwork'; // Inside the Image component: const {isOffline} = useNetwork(); const source = useMemo(() => { if (typeof propsSource === 'object' && 'uri' in propsSource) { if (typeof propsSource.uri === 'number') { return propsSource.uri; } const authToken = session?.encryptedAuthToken ?? null; if (isAuthTokenRequired && authToken) { - if (!!session?.creationDate && !isExpiredSession(session.creationDate)) { + if (isOffline || (!!session?.creationDate && !isExpiredSession(session.creationDate))) { return { ...propsSource, cacheKey: propsSource.uri, headers: { [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, }, }; }
When offline, we always provide the source with its
cacheKey, allowing expo-image/SDWebImage to serve the cached image from disk. When back online, the normal session expiration and reauthentication flow resumes. We should also addisOfflineto theuseMemodependency array.Contributor details
Your Expensify account email: trasnake87@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~010f770315ab181656✅ Contributor details stored successfully. Thank you for contributing to Expensify!
Proposal
Please re-state the problem that we are trying to solve in this issue.
When a user opens a chat with expense receipts, lets the receipt images load, navigates away, and then returns to that chat while fully offline, previously loaded receipt images are not displayed. They only reappear after internet connectivity is restored.
What is the root cause of that problem?
The receipt image source for authenticated attachments is built with request headers (
X-Chat-Attachment-Token), but the native image rendering path does not explicitly persist those authenticated images to disk. As a result, after navigating away and remounting the chat while offline, the image request cannot be fulfilled from a durable disk cache and the receipt image fails to render until network access returns.App/src/components/Image/index.tsx
Lines 116 to 131 in 6bb17bf
const source = useMemo(() => { if (typeof propsSource === 'object' && 'uri' in propsSource) { if (typeof propsSource.uri === 'number') { return propsSource.uri; } const authToken = session?.encryptedAuthToken ?? null; if (isAuthTokenRequired && authToken) { if (!!session?.creationDate && !isExpiredSession(session.creationDate)) { return { ...propsSource, cacheKey: propsSource.uri, headers: { [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, }, }; } App/src/components/Image/BaseImage.native.tsx
Lines 45 to 53 in 6bb17bf
return ( <ExpoImage // Only subscribe to onLoad when a handler is provided to avoid unnecessary event registrations, optimizing performance. onLoad={onLoad ? imageLoadedSuccessfully : undefined} source={source} recyclingKey={getImageRecyclingKey(source)} style={style as ExpoImageProps['style']} // eslint-disable-next-line react/jsx-props-no-spreading {...props} What changes do you think we should make in order to solve the problem?
We should default authenticated image sources to
memory-diskcaching in the native base image component, while still respecting any explicitcachePolicypassed by callers. This ensures receipts that were already fetched can be rendered from disk when the chat remounts offline.App/src/components/Image/BaseImage.native.tsx
Lines 9 to 53 in 6bb17bf
function BaseImage({onLoad, source, style, ...props}: BaseImageProps) { const isLoadedRef = useRef(false); const attachmentContext = useContext(AttachmentStateContext); const {setAttachmentLoaded, isAttachmentLoaded} = attachmentContext || {}; useEffect(() => { if (isAttachmentLoaded?.(source as AttachmentSource)) { return; } setAttachmentLoaded(source as AttachmentSource, false); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Reset isLoadedRef when source changes to allow onLoad to fire again for new images (e.g., after rotation) useEffect(() => { isLoadedRef.current = false; }, [source]); const imageLoadedSuccessfully = useCallback( (event: ImageLoadEventData) => { setAttachmentLoaded(source as AttachmentSource, true); if (!onLoad) { return; } if (isLoadedRef.current === true) { return; } // We override `onLoad`, so both web and native have the same signature const {width, height} = event.source; isLoadedRef.current = true; onLoad({nativeEvent: {width, height}}); }, [onLoad, setAttachmentLoaded, source], ); return ( <ExpoImage // Only subscribe to onLoad when a handler is provided to avoid unnecessary event registrations, optimizing performance. onLoad={onLoad ? imageLoadedSuccessfully : undefined} source={source} recyclingKey={getImageRecyclingKey(source)} style={style as ExpoImageProps['style']} // eslint-disable-next-line react/jsx-props-no-spreading {...props} function BaseImage({onLoad, source, style, cachePolicy, ...props}: BaseImageProps) { const isLoadedRef = useRef(false); const attachmentContext = useContext(AttachmentStateContext); const {setAttachmentLoaded, isAttachmentLoaded} = attachmentContext || {}; // ...existing logic const shouldForceDiskCachingForAuthImage = typeof source === 'object' && source !== null && !Array.isArray(source) && 'headers' in source && !!source.headers; return ( <ExpoImage onLoad={onLoad ? imageLoadedSuccessfully : undefined} source={source} cachePolicy={cachePolicy ?? (shouldForceDiskCachingForAuthImage ? 'memory-disk' : undefined)} recyclingKey={getImageRecyclingKey(source)} style={style as ExpoImageProps['style']} {...props} /> ); }
@VickyStash (as author of #83217), are you interested in fixing this issue?
54 remaining items
Payment Summary
- Reviewer: @mkhutornyi owed $250 via NewDot
- ROLE: @marufsharifi paid $(AMOUNT) via Upwork (LINK)
BugZero Checklist (@mallenexpensify)
- (if NewFeature) I have created a PR for any necessary HelpDot updates, or I confirmed no updates are necessary.
- I have verified the correct assignees and roles are listed above and updated the necessary manual offers
- I have verified that there are no duplicate or incorrect contracts on Upwork for this job (https://www.upwork.com/ab/applicants/2038647517986379009/hired)
- I have verified the PR was not reverted
- I have applied any discounts due to bugs/regressions introduced by this PR
- I have paid out the Upwork contracts or cancelled the ones that are incorrect
- I have verified the payment summary above is correct
Payment Summary
Contributor: @marufsharifi paid $250 via Upwork
Contributor+: @mkhutornyi due $250 via NewDot@marufsharifi can you please accept the job below? Please reply here and tag me once you have.
^ Test case created
Contributor details
Your Expensify account email: maruf.sharifi.work@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~011cdea57d98112825✅ Contributor details stored successfully. Thank you for contributing to Expensify!
@mallenexpensify, Just to confirm, should I accept the offer or wait? Thanks.
@marufsharifi please accept the offer!
@mallenexpensify, I've accepted the offer. thanks.
approved $250 for @mkhutornyi
@marufsharifi paid, summary updated above. Thx.
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
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: 9.3.38-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: #84409
Email or phone of affected tester (no customers): fischer9966+053122@gmail.com
Issue reported by: Applause Internal Team
Bug source: Pull Request QA execution
Device used: iPhone 16 Pro / 26.3.1
App Component: Chat Report View
Action Performed:
Expected Result:
Cached receipt images are displayed offline in the chat
Actual Result:
Cached receipt images are not displayed offline in the chat. Images appear once the connection is restored.
Workaround:
Unknown
Platforms:
Screenshots/Videos
Bug7104722_1773702316857.84409-iOS.mp4
View all open jobs on GitHub
Issue Owner
Current Issue Owner: @mallenexpensifyUpwork Automation - Do Not Edit