Skip to content

[Due for payment 2026-04-30] [$250] iOS - Cached images are not displayed offline #86666

Description

@lanitochka17

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:

  1. Open Expensify iOS app
  2. Login with any account
  3. Open the chat with expenses with receipts images
  4. Make sure receipts images are loaded
  5. Go back to inbox or any other page
  6. Turn off the internet connection (real offline, not simulation)
  7. Return to the chat from step 3
  8. Verify the receipts images that were loaded previously are displayed.

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:

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

Screenshots/Videos

Bug7104722_1773702316857.84409-iOS.mp4

View all open jobs on GitHub

Issue OwnerCurrent Issue Owner: @mallenexpensify
Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022038647517986379009
  • Upwork Job ID: 2038647517986379009
  • Last Price Increase: 2026-04-13

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Mar 30, 2026
  2. MelvinBot commented on Mar 30, 2026

    @MelvinBot
    Contributor

    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.tsx uses the useCachedImageSource hook, 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.tsx does not use useCachedImageSource (it uses browser-only APIs: window.caches, URL.createObjectURL). Instead, it passes the image source (with auth headers and cacheKey) 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: cacheAttachment only runs when the user sends an attachment (not when viewing one), and getCachedAttachment is exported but never called by any rendering component.

    The flow for receipt thumbnails on iOS:

    1. Image/index.tsx wraps the receipt URL with headers: { X-Chat-Attachment-Token: authToken } and cacheKey: uri
    2. BaseImage.native.tsx passes this directly to <ExpoImage> with no caching hook
    3. expo-image/SDWebImage fetches, displays, and internally caches the image
    4. When the user goes offline, if SDWebImage has evicted the image from cache, it fails to load → ThumbnailImage shows 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:

    1. On first load (online), downloads the authenticated image to a persistent local file using react-native-blob-util (already a dependency) — similar to how cacheAttachment works, but triggered at render/view time rather than upload time
    2. Stores a mapping of uri → localFilePath (could use Onyx's existing ONYXKEYS.COLLECTION.ATTACHMENT or a new in-memory Map with file system backing)
    3. On subsequent renders (including offline), checks for the local cached file first and returns its file:// URI instead of the remote URL
    4. Import and use this hook in BaseImage.native.tsx the same way BaseImage.tsx uses 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)

    1. 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.

    2. Connecting the existing getCachedAttachment to the rendering pipeline: The infrastructure (cacheAttachment/getCachedAttachment in index.native.ts) partially exists. However, it would need significant rework since cacheAttachment currently 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


    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

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

    @melvin-bot

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

  6. changed the title [-]iOS - Cached images are not displayed offline[/-] [+][$250] iOS - Cached images are not displayed offline[/+] on Mar 30, 2026
  7. melvin-bot commented on Mar 30, 2026

    @melvin-bot
  8. trasnake87 commented on Mar 30, 2026

    @trasnake87
    Contributor

    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 Image wrapper component at https://github.com/Expensify/App/blob/main/src/components/Image/index.tsx#L116-L143. When the session's creationDate is older than 2 hours (SESSION_EXPIRATION_TIME_MS), the isExpiredSession check at line 123 fails, causing the code to fall through to line 132-135 where it calls activateReauthenticator(session) and returns undefined as the image source. This undefined source causes the component to render a LoadingIndicator (lines 159-165) instead of passing the source to expo-image. Since the reauthenticator bails out when offline (line 68 of AttachmentImageReauthenticator.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 the cacheKey.

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

    We should check the network status in Image/index.tsx and, when offline, skip the session expiration check and pass the source through with the existing auth token. Expo-image caches by cacheKey (which is set to propsSource.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 add isOffline to the useMemo dependency array.

  9. trasnake87 commented on Mar 30, 2026

    @trasnake87
    Contributor

    Contributor details
    Your Expensify account email: trasnake87@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~010f770315ab181656

  10. melvin-bot commented on Mar 30, 2026

    @melvin-bot

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

  11. abbasifaizan70 commented on Mar 30, 2026

    @abbasifaizan70
    Contributor

    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.

    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,
    },
    };
    }

    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-disk caching in the native base image component, while still respecting any explicit cachePolicy passed by callers. This ensures receipts that were already fetched can be rendered from disk when the chat remounts offline.

    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}
            />
        );
    }
  12. mkhutornyi commented on Mar 30, 2026

    @mkhutornyi
    Contributor

    @VickyStash (as author of #83217), are you interested in fixing this issue?

  13. 54 remaining items

  14. melvin-bot commented on Apr 30, 2026

    @melvin-bot

    Payment Summary

    Upwork Job

    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
  15. mallenexpensify commented on May 1, 2026

    @mallenexpensify
    Contributor

    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

  16. marufsharifi commented on May 2, 2026

    @marufsharifi
    Contributor

    Contributor details
    Your Expensify account email: maruf.sharifi.work@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~011cdea57d98112825

  17. melvin-bot commented on May 2, 2026

    @melvin-bot

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

  18. marufsharifi commented on May 4, 2026

    @marufsharifi
    Contributor

    @mallenexpensify, Just to confirm, should I accept the offer or wait? Thanks.

  19. mallenexpensify commented on May 4, 2026

    @mallenexpensify
    Contributor

    @marufsharifi please accept the offer!

  20. marufsharifi commented on May 5, 2026

    @marufsharifi
    Contributor

    @mallenexpensify, I've accepted the offer. thanks.

  21. added and removed on May 5, 2026
  22. trjExpensify commented on May 6, 2026

    @trjExpensify
    Contributor

    approved $250 for @mkhutornyi

  23. mallenexpensify commented on May 6, 2026

    @mallenexpensify
    Contributor

    @marufsharifi paid, summary updated above. Thx.

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.DailyKSv2ExternalAdded 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