Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9243,6 +9243,10 @@ const CONST = {
HEADER: 'header',
ROW: 'row',
},

CACHE_NAME: {
AUTH_IMAGES: 'auth-images',
},
} as const;

const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [
Expand Down
6 changes: 5 additions & 1 deletion src/components/Image/BaseImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -43,7 +47,7 @@ function BaseImage({onLoad, onLoadStart, source, ...props}: BaseImageProps) {
<ExpoImage
// Only subscribe to onLoad when a handler is provided to avoid unnecessary event registrations, optimizing performance.
onLoad={onLoad ? imageLoadedSuccessfully : undefined}
source={source}
source={resolvedSource}
recyclingKey={getImageRecyclingKey(source)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
Expand Down
110 changes: 110 additions & 0 deletions src/hooks/useCachedImageSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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;
const [cachedUri, setCachedUri] = useState<string | null>(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};
8 changes: 4 additions & 4 deletions src/libs/actions/Session/clearCache/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {clearAuthImagesCache} from '@hooks/useCachedImageSource';
import type ClearCache from './types';

const clearStorage: ClearCache = () =>
new Promise<void>((resolve) => {
resolve();
});
const clearStorage: ClearCache = async () => {
await clearAuthImagesCache();
};

export default clearStorage;
181 changes: 181 additions & 0 deletions tests/unit/hooks/useCachedImageSource.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading