diff --git a/src/CONST/index.ts b/src/CONST/index.ts index aa699de640f12..6edc0cae6f487 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9243,6 +9243,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/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) { { + 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; + const [cachedUri, setCachedUri] = useState(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(CONST.CACHE_NAME.AUTH_IMAGES); + 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 (error) { + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + await clearAuthImagesCache(); + } + 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; +export {clearAuthImagesCache}; diff --git a/src/libs/actions/Session/clearCache/index.ts b/src/libs/actions/Session/clearCache/index.ts index 3daa8ec2d7d74..a587b99a0af0e 100644 --- a/src/libs/actions/Session/clearCache/index.ts +++ b/src/libs/actions/Session/clearCache/index.ts @@ -1,8 +1,8 @@ +import {clearAuthImagesCache} from '@hooks/useCachedImageSource'; import type ClearCache from './types'; -const clearStorage: ClearCache = () => - new Promise((resolve) => { - resolve(); - }); +const clearStorage: ClearCache = async () => { + await clearAuthImagesCache(); +}; export default clearStorage; 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); + }); +});