From d51a0223297cdf9990ef8d067725a819833e4d34 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 6 Mar 2026 14:49:37 +0100 Subject: [PATCH 1/5] Add caching for images with authentication on web --- src/components/Image/BaseImage.tsx | 6 +- src/hooks/useCachedImageSource.ts | 94 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useCachedImageSource.ts diff --git a/src/components/Image/BaseImage.tsx b/src/components/Image/BaseImage.tsx index 7a20a332cb3dd..b8324912afef8 100644 --- a/src/components/Image/BaseImage.tsx +++ b/src/components/Image/BaseImage.tsx @@ -2,11 +2,15 @@ import {Image as ExpoImage} from 'expo-image'; import type {ImageLoadEventData} from 'expo-image'; import React, {useCallback, useContext, useEffect} from 'react'; import type {AttachmentSource} from '@components/Attachments/types'; +import useCachedImageSource from '@hooks/useCachedImageSource'; import getImageRecyclingKey from '@libs/getImageRecyclingKey'; import {AttachmentStateContext} from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent/AttachmentStateContextProvider'; import type {BaseImageProps} from './types'; function BaseImage({onLoad, onLoadStart, source, ...props}: BaseImageProps) { + const cachedSource = useCachedImageSource(typeof source === 'object' && !Array.isArray(source) ? source : undefined); + const resolvedSource = cachedSource !== undefined ? cachedSource : source; + const {setAttachmentLoaded, isAttachmentLoaded} = useContext(AttachmentStateContext); useEffect(() => { if (isAttachmentLoaded?.(source as AttachmentSource)) { @@ -43,7 +47,7 @@ function BaseImage({onLoad, onLoadStart, source, ...props}: BaseImageProps) { (null); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + setCachedUri(null); + setHasError(false); + + if (!hasHeaders || !uri) { + return; + } + + let revoked = false; + let objectURL: string | undefined; + + (async () => { + try { + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(uri); + + if (cachedResponse) { + const blob = await cachedResponse.blob(); + objectURL = URL.createObjectURL(blob); + if (!revoked) { + setCachedUri(objectURL); + } else { + URL.revokeObjectURL(objectURL); + } + return; + } + + const response = await fetch(uri, {headers: source.headers}); + + if (!response.ok) { + if (!revoked) { + setHasError(true); + } + return; + } + + // Store in cache before consuming + await cache.put(uri, response.clone()); + + const blob = await response.blob(); + objectURL = URL.createObjectURL(blob); + if (!revoked) { + setCachedUri(objectURL); + } else { + URL.revokeObjectURL(objectURL); + } + } catch { + if (!revoked) { + setHasError(true); + } + } + })(); + + return () => { + revoked = true; + if (objectURL) { + URL.revokeObjectURL(objectURL); + } + }; + }, [uri, hasHeaders, source?.headers]); + + // Images without headers are cached natively by the browser, + // so pass them through as-is — no Cache API needed + if (!hasHeaders) { + return source; + } + + // If caching failed, fall back to the original source so expo-image + // handles it normally (including error reporting via onError) + if (hasError) { + return source; + } + + // Cache fetch is still in progress — return null so expo-image doesn't + // render the image with headers (which would bypass our cache) + if (!cachedUri) { + return null; + } + + return {uri: cachedUri}; +} + +export default useCachedImageSource; From 8ed16c55b472c10cbccb3c56b959f5afe30a032f Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 11 Mar 2026 10:39:12 +0100 Subject: [PATCH 2/5] Clear auth-images cache on logout --- src/CONST/index.ts | 4 ++++ src/hooks/useCachedImageSource.ts | 5 ++--- src/libs/actions/Session/clearCache/index.ts | 17 +++++++++++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f9d3b3877584d..384350b68b69d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9141,6 +9141,10 @@ const CONST = { HEADER: 'header', ROW: 'row', }, + + CACHE_NAME: { + AUTH_IMAGES: 'auth-images', + }, } as const; const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [ diff --git a/src/hooks/useCachedImageSource.ts b/src/hooks/useCachedImageSource.ts index e9676fca7a185..b939d12090bac 100644 --- a/src/hooks/useCachedImageSource.ts +++ b/src/hooks/useCachedImageSource.ts @@ -1,7 +1,6 @@ import type {ImageSource} from 'expo-image'; import {useEffect, useState} from 'react'; - -const CACHE_NAME = 'auth-images'; +import CONST from '@src/CONST'; function useCachedImageSource(source: ImageSource | undefined): ImageSource | null | undefined { const uri = typeof source === 'object' ? source.uri : undefined; @@ -22,7 +21,7 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu (async () => { try { - const cache = await caches.open(CACHE_NAME); + const cache = await caches.open(CONST.CACHE_NAME.AUTH_IMAGES); const cachedResponse = await cache.match(uri); if (cachedResponse) { diff --git a/src/libs/actions/Session/clearCache/index.ts b/src/libs/actions/Session/clearCache/index.ts index 3daa8ec2d7d74..63e3d9bfe44ca 100644 --- a/src/libs/actions/Session/clearCache/index.ts +++ b/src/libs/actions/Session/clearCache/index.ts @@ -1,8 +1,17 @@ +import Log from '@libs/Log'; +import CONST from '@src/CONST'; import type ClearCache from './types'; -const clearStorage: ClearCache = () => - new Promise((resolve) => { - resolve(); - }); +const clearStorage: ClearCache = async () => { + if (!('caches' in window)) { + return; + } + + try { + await caches.delete(CONST.CACHE_NAME.AUTH_IMAGES); + } catch (error) { + Log.alert('[AuthImageCache] Error clearing auth image cache:', {message: (error as Error).message}); + } +}; export default clearStorage; From ee2b8aea71ed8faa4f43c8971ccf098c9e3eadf4 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 11 Mar 2026 11:39:27 +0100 Subject: [PATCH 3/5] Clear auth-images cache if quota exceeded --- src/hooks/useCachedImageSource.ts | 19 ++++++++++++++++++- src/libs/actions/Session/clearCache/index.ts | 13 ++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/hooks/useCachedImageSource.ts b/src/hooks/useCachedImageSource.ts index b939d12090bac..833e42c2adebf 100644 --- a/src/hooks/useCachedImageSource.ts +++ b/src/hooks/useCachedImageSource.ts @@ -1,7 +1,20 @@ import type {ImageSource} from 'expo-image'; import {useEffect, useState} from 'react'; +import Log from '@libs/Log'; import CONST from '@src/CONST'; +const clearAuthImagesCache = async () => { + if (!('caches' in window)) { + return; + } + + try { + await caches.delete(CONST.CACHE_NAME.AUTH_IMAGES); + } catch (error) { + Log.alert('[AuthImageCache] Error clearing auth image cache:', {message: (error as Error).message}); + } +}; + function useCachedImageSource(source: ImageSource | undefined): ImageSource | null | undefined { const uri = typeof source === 'object' ? source.uri : undefined; const hasHeaders = typeof source === 'object' && !!source.headers; @@ -54,7 +67,10 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu } else { URL.revokeObjectURL(objectURL); } - } catch { + } catch (error) { + if ((error as Error).message?.includes('Quota exceeded')) { + await clearAuthImagesCache(); + } if (!revoked) { setHasError(true); } @@ -91,3 +107,4 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu } export default useCachedImageSource; +export {clearAuthImagesCache}; diff --git a/src/libs/actions/Session/clearCache/index.ts b/src/libs/actions/Session/clearCache/index.ts index 63e3d9bfe44ca..a587b99a0af0e 100644 --- a/src/libs/actions/Session/clearCache/index.ts +++ b/src/libs/actions/Session/clearCache/index.ts @@ -1,17 +1,8 @@ -import Log from '@libs/Log'; -import CONST from '@src/CONST'; +import {clearAuthImagesCache} from '@hooks/useCachedImageSource'; import type ClearCache from './types'; const clearStorage: ClearCache = async () => { - if (!('caches' in window)) { - return; - } - - try { - await caches.delete(CONST.CACHE_NAME.AUTH_IMAGES); - } catch (error) { - Log.alert('[AuthImageCache] Error clearing auth image cache:', {message: (error as Error).message}); - } + await clearAuthImagesCache(); }; export default clearStorage; From d091a81a749a7e2662da04ae8859bc897ff7cb89 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 13 Mar 2026 13:18:36 +0100 Subject: [PATCH 4/5] Improve the error check --- src/hooks/useCachedImageSource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useCachedImageSource.ts b/src/hooks/useCachedImageSource.ts index 833e42c2adebf..b806c080cab1a 100644 --- a/src/hooks/useCachedImageSource.ts +++ b/src/hooks/useCachedImageSource.ts @@ -68,7 +68,7 @@ function useCachedImageSource(source: ImageSource | undefined): ImageSource | nu URL.revokeObjectURL(objectURL); } } catch (error) { - if ((error as Error).message?.includes('Quota exceeded')) { + if (error instanceof DOMException && error.name === 'QuotaExceededError') { await clearAuthImagesCache(); } if (!revoked) { From 4a5462257f2ae7374ea0e5b40cb9276601b9fc57 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 13 Mar 2026 15:17:43 +0100 Subject: [PATCH 5/5] Add useCachedImageSource jest tests --- tests/unit/hooks/useCachedImageSource.test.ts | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/unit/hooks/useCachedImageSource.test.ts diff --git a/tests/unit/hooks/useCachedImageSource.test.ts b/tests/unit/hooks/useCachedImageSource.test.ts new file mode 100644 index 0000000000000..c93e9e8367f2b --- /dev/null +++ b/tests/unit/hooks/useCachedImageSource.test.ts @@ -0,0 +1,181 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; +import type {ImageSource} from 'expo-image'; +import useCachedImageSource from '@hooks/useCachedImageSource'; +import CONST from '@src/CONST'; + +const MOCK_URI = 'https://example.com/image.png'; +// eslint-disable-next-line @typescript-eslint/naming-convention +const MOCK_HEADERS = {'X-Auth-Token': 'token123'}; +const MOCK_BLOB = new Blob(['image-data'], {type: 'image/png'}); +const MOCK_BLOB_URL = 'blob:http://localhost/mock-blob-url'; + +let mockCacheMatch: jest.Mock; +let mockCachePut: jest.Mock; +let mockCachesOpen: jest.Mock; +let mockCachesDelete: jest.Mock; +let mockCreateObjectURL: jest.Mock; +let mockRevokeObjectURL: jest.Mock; + +const createMockResponse = (ok = true) => { + const response = { + ok, + blob: jest.fn().mockResolvedValue(MOCK_BLOB), + clone: jest.fn(), + }; + response.clone.mockReturnValue(response); + return response as unknown as Response; +}; + +beforeEach(() => { + mockCacheMatch = jest.fn().mockResolvedValue(null); + mockCachePut = jest.fn().mockResolvedValue(undefined); + mockCachesOpen = jest.fn().mockResolvedValue({match: mockCacheMatch, put: mockCachePut}); + mockCachesDelete = jest.fn().mockResolvedValue(true); + + const cachesMock = { + open: mockCachesOpen, + delete: mockCachesDelete, + has: jest.fn().mockResolvedValue(false), + keys: jest.fn().mockResolvedValue([]), + match: jest.fn().mockResolvedValue(undefined), + }; + Object.defineProperty(window, 'caches', {value: cachesMock, writable: true, configurable: true}); + + jest.spyOn(global, 'fetch').mockResolvedValue(createMockResponse()); + mockCreateObjectURL = jest.fn().mockReturnValue(MOCK_BLOB_URL); + mockRevokeObjectURL = jest.fn(); + global.URL.createObjectURL = mockCreateObjectURL; + global.URL.revokeObjectURL = mockRevokeObjectURL; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('useCachedImageSource', () => { + it('should return source as-is when it has no headers', () => { + const source: ImageSource = {uri: MOCK_URI}; + const {result} = renderHook(() => useCachedImageSource(source)); + expect(result.current).toBe(source); + }); + + it('should return undefined when source is undefined', () => { + const {result} = renderHook(() => useCachedImageSource(undefined)); + expect(result.current).toBeUndefined(); + }); + + it('should return null while cache fetch is in progress', () => { + const source: ImageSource = {uri: MOCK_URI, headers: MOCK_HEADERS}; + const {result} = renderHook(() => useCachedImageSource(source)); + + // Initially null while the async effect runs + expect(result.current).toBeNull(); + }); + + it('should return blob URL from cache hit', async () => { + const cachedResponse = {blob: jest.fn().mockResolvedValue(MOCK_BLOB)}; + mockCacheMatch.mockResolvedValue(cachedResponse); + + const source: ImageSource = {uri: MOCK_URI, headers: MOCK_HEADERS}; + const {result} = renderHook(() => useCachedImageSource(source)); + + await waitFor(() => { + expect(result.current).toEqual({uri: MOCK_BLOB_URL}); + }); + + expect(mockCachesOpen).toHaveBeenCalledWith(CONST.CACHE_NAME.AUTH_IMAGES); + expect(mockCacheMatch).toHaveBeenCalledWith(MOCK_URI); + expect(mockCreateObjectURL).toHaveBeenCalledWith(MOCK_BLOB); + }); + + it('should fetch, cache, and return blob URL on cache miss', async () => { + const mockResponse = createMockResponse(); + jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const source: ImageSource = {uri: MOCK_URI, headers: MOCK_HEADERS}; + const {result} = renderHook(() => useCachedImageSource(source)); + + await waitFor(() => { + expect(result.current).toEqual({uri: MOCK_BLOB_URL}); + }); + + expect(global.fetch).toHaveBeenCalledWith(MOCK_URI, {headers: MOCK_HEADERS}); + expect(mockCachePut).toHaveBeenCalledWith(MOCK_URI, mockResponse); + expect(mockCreateObjectURL).toHaveBeenCalledWith(MOCK_BLOB); + }); + + it('should fall back to original source when fetch fails', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(createMockResponse(false)); + + const source: ImageSource = {uri: MOCK_URI, headers: MOCK_HEADERS}; + const {result} = renderHook(() => useCachedImageSource(source)); + + await waitFor(() => { + expect(result.current).toBe(source); + }); + }); + + it('should clear cache and fall back on QuotaExceededError', async () => { + const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError'); + mockCacheMatch.mockRejectedValue(quotaError); + + const source: ImageSource = {uri: MOCK_URI, headers: MOCK_HEADERS}; + const {result} = renderHook(() => useCachedImageSource(source)); + + await waitFor(() => { + expect(result.current).toBe(source); + }); + + expect(mockCachesDelete).toHaveBeenCalledWith(CONST.CACHE_NAME.AUTH_IMAGES); + }); + + it('should not clear cache on non-quota errors', async () => { + mockCacheMatch.mockRejectedValue(new Error('Network error')); + + const source: ImageSource = {uri: MOCK_URI, headers: MOCK_HEADERS}; + const {result} = renderHook(() => useCachedImageSource(source)); + + await waitFor(() => { + expect(result.current).toBe(source); + }); + + expect(mockCachesDelete).not.toHaveBeenCalled(); + }); + + it('should revoke object URL on unmount', async () => { + const source: ImageSource = {uri: MOCK_URI, headers: MOCK_HEADERS}; + const {result, unmount} = renderHook(() => useCachedImageSource(source)); + + await waitFor(() => { + expect(result.current).toEqual({uri: MOCK_BLOB_URL}); + }); + + unmount(); + + expect(mockRevokeObjectURL).toHaveBeenCalledWith(MOCK_BLOB_URL); + }); + + it('should reset and re-fetch when URI changes', async () => { + const source1: ImageSource = {uri: MOCK_URI, headers: MOCK_HEADERS}; + const source2: ImageSource = {uri: 'https://example.com/other.png', headers: MOCK_HEADERS}; + + const secondBlobUrl = 'blob:http://localhost/second-blob-url'; + + const {result, rerender} = renderHook(({source}: {source: ImageSource}) => useCachedImageSource(source), {initialProps: {source: source1}}); + + await waitFor(() => { + expect(result.current).toEqual({uri: MOCK_BLOB_URL}); + }); + + mockCreateObjectURL.mockReturnValue(secondBlobUrl); + + rerender({source: source2}); + + await waitFor(() => { + expect(result.current).toEqual({uri: secondBlobUrl}); + }); + + // Old URL should be revoked during cleanup + expect(mockRevokeObjectURL).toHaveBeenCalledWith(MOCK_BLOB_URL); + }); +});