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:
Sergei 2026-01-29 11:22:49 -08:00
parent f69ddb7538
commit 74a4c9e8f4
4 changed files with 106 additions and 5 deletions

View File

@ -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>

View File

@ -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]}>

View 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);
}
});
});

View File

@ -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}`;
}