-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Add caching for images with authentication on web #84409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Julesssss
merged 7 commits into
Expensify:main
from
callstack-internal:VickyStash/feature/83112-cache-auth-images
Mar 13, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d51a022
Add caching for images with authentication on web
VickyStash 8ed16c5
Clear auth-images cache on logout
VickyStash 903dce5
Merge branch 'main' into VickyStash/feature/83112-cache-auth-images
VickyStash ee2b8ae
Clear auth-images cache if quota exceeded
VickyStash 7412b7c
Merge branch 'main' into VickyStash/feature/83112-cache-auth-images
VickyStash d091a81
Improve the error check
VickyStash 4a54622
Add useCachedImageSource jest tests
VickyStash File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
Julesssss marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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}; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.