Fix avatar caching after upload with cache-busting
Implemented cache-busting mechanism to prevent stale avatar images after upload. React Native Image component caches images by URI, causing old avatars to persist even after successful upload. Changes: - Added bustImageCache() utility function in utils/imageUtils.ts - Appends timestamp query parameter (?t=timestamp) to avatar URLs - Skips cache-busting for local file://, data: URIs and placeholders - Applied bustImageCache() to all avatar Image components: - Beneficiary detail screen (header, edit modal, lightbox) - Beneficiary list cards on dashboard - Ensured loadBeneficiary() is called after avatar upload completes - Added comprehensive unit tests for cache-busting logic Backend already generates unique URLs with timestamps when uploading to MinIO, but this ensures frontend always requests fresh images. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f69ddb7538
commit
74a4c9e8f4
@ -43,6 +43,7 @@ import {
|
||||
} from '@/services/BeneficiaryDetailController';
|
||||
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
|
||||
import { ImageLightbox } from '@/components/ImageLightbox';
|
||||
import { bustImageCache } from '@/utils/imageUtils';
|
||||
|
||||
// WebView Dashboard URL - opens specific deployment directly
|
||||
const getDashboardUrl = (deploymentId?: number) => {
|
||||
@ -298,7 +299,8 @@ export default function BeneficiaryDetailScreen() {
|
||||
|
||||
setIsEditModalVisible(false);
|
||||
toast.success('Saved', isCustodian ? 'Profile updated successfully' : 'Nickname saved');
|
||||
loadBeneficiary(false);
|
||||
// Reload to get updated data with fresh avatar URL (cache-busting timestamp will be applied)
|
||||
await loadBeneficiary(false);
|
||||
} catch (err) {
|
||||
toast.error('Error', 'Failed to save changes.');
|
||||
} finally {
|
||||
@ -389,7 +391,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
disabled={!beneficiary.avatar || beneficiary.avatar.trim() === '' || beneficiary.avatar.includes('placeholder')}
|
||||
>
|
||||
{beneficiary.avatar && beneficiary.avatar.trim() !== '' && !beneficiary.avatar.includes('placeholder') ? (
|
||||
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
|
||||
<Image source={{ uri: bustImageCache(beneficiary.avatar) || undefined }} style={styles.headerAvatarImage} />
|
||||
) : (
|
||||
<View style={styles.headerAvatar}>
|
||||
<Text style={styles.headerAvatarText}>
|
||||
@ -526,7 +528,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
disabled={isSavingEdit}
|
||||
>
|
||||
{editForm.avatar ? (
|
||||
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
|
||||
<Image source={{ uri: bustImageCache(editForm.avatar) || undefined }} style={styles.avatarPickerImage} />
|
||||
) : (
|
||||
<View style={styles.avatarPickerPlaceholder}>
|
||||
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
|
||||
@ -625,7 +627,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
{/* Avatar Lightbox */}
|
||||
<ImageLightbox
|
||||
visible={lightboxVisible}
|
||||
imageUri={beneficiary?.avatar || null}
|
||||
imageUri={bustImageCache(beneficiary?.avatar)}
|
||||
onClose={() => setLightboxVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
} from '@/constants/theme';
|
||||
import type { Beneficiary } from '@/types';
|
||||
import { isSubscriptionActive } from '@/services/subscription';
|
||||
import { bustImageCache } from '@/utils/imageUtils';
|
||||
|
||||
// Beneficiary card with equipment status support
|
||||
interface BeneficiaryCardProps {
|
||||
@ -115,7 +116,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
||||
{/* Avatar */}
|
||||
<View style={styles.avatarWrapper}>
|
||||
{hasValidAvatar ? (
|
||||
<Image source={{ uri: beneficiary.avatar }} style={styles.avatarImage} />
|
||||
<Image source={{ uri: bustImageCache(beneficiary.avatar) || undefined }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
|
||||
<Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
|
||||
|
||||
65
utils/__tests__/imageUtils.test.ts
Normal file
65
utils/__tests__/imageUtils.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { bustImageCache } from '../imageUtils';
|
||||
|
||||
describe('bustImageCache', () => {
|
||||
it('should add cache-busting parameter to HTTP URLs', () => {
|
||||
const url = 'https://example.com/avatar.jpg';
|
||||
const result = bustImageCache(url, 1234567890);
|
||||
expect(result).toBe('https://example.com/avatar.jpg?t=1234567890');
|
||||
});
|
||||
|
||||
it('should add cache-busting parameter to URLs with existing query params', () => {
|
||||
const url = 'https://example.com/avatar.jpg?size=large';
|
||||
const result = bustImageCache(url, 1234567890);
|
||||
expect(result).toBe('https://example.com/avatar.jpg?size=large&t=1234567890');
|
||||
});
|
||||
|
||||
it('should not add cache-busting to file:// URIs', () => {
|
||||
const uri = 'file:///path/to/local/image.jpg';
|
||||
const result = bustImageCache(uri);
|
||||
expect(result).toBe(uri);
|
||||
});
|
||||
|
||||
it('should not add cache-busting to data URIs', () => {
|
||||
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA';
|
||||
const result = bustImageCache(dataUri);
|
||||
expect(result).toBe(dataUri);
|
||||
});
|
||||
|
||||
it('should not add cache-busting to placeholder images', () => {
|
||||
const placeholderUrl = 'https://example.com/placeholder.png';
|
||||
const result = bustImageCache(placeholderUrl);
|
||||
expect(result).toBe(placeholderUrl);
|
||||
});
|
||||
|
||||
it('should return null for null input', () => {
|
||||
const result = bustImageCache(null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for undefined input', () => {
|
||||
const result = bustImageCache(undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
const result = bustImageCache('');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should use current timestamp when not provided', () => {
|
||||
const url = 'https://example.com/avatar.jpg';
|
||||
const beforeTimestamp = Date.now();
|
||||
const result = bustImageCache(url);
|
||||
const afterTimestamp = Date.now();
|
||||
|
||||
// Extract timestamp from result
|
||||
const match = result?.match(/t=(\d+)/);
|
||||
expect(match).toBeTruthy();
|
||||
|
||||
if (match) {
|
||||
const timestamp = parseInt(match[1], 10);
|
||||
expect(timestamp).toBeGreaterThanOrEqual(beforeTimestamp);
|
||||
expect(timestamp).toBeLessThanOrEqual(afterTimestamp);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -51,3 +51,36 @@ export async function optimizeImage(
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add cache-busting query parameter to image URL
|
||||
* Prevents browsers and React Native Image from caching old versions
|
||||
*
|
||||
* @param uri - Image URI (can be http, https, or file://)
|
||||
* @param timestamp - Optional timestamp (defaults to current time)
|
||||
* @returns URI with cache-busting parameter
|
||||
*/
|
||||
export function bustImageCache(uri: string | null | undefined, timestamp?: number): string | null {
|
||||
if (!uri || uri.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't add cache buster to local file URIs (file://)
|
||||
if (uri.startsWith('file://')) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
// Don't add cache buster to data URIs
|
||||
if (uri.startsWith('data:')) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
// Don't add cache buster to placeholder images
|
||||
if (uri.includes('placeholder')) {
|
||||
return uri;
|
||||
}
|
||||
|
||||
const cacheBuster = timestamp || Date.now();
|
||||
const separator = uri.includes('?') ? '&' : '?';
|
||||
return `${uri}${separator}t=${cacheBuster}`;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user