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';
|
} from '@/services/BeneficiaryDetailController';
|
||||||
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
|
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
|
||||||
import { ImageLightbox } from '@/components/ImageLightbox';
|
import { ImageLightbox } from '@/components/ImageLightbox';
|
||||||
|
import { bustImageCache } from '@/utils/imageUtils';
|
||||||
|
|
||||||
// WebView Dashboard URL - opens specific deployment directly
|
// WebView Dashboard URL - opens specific deployment directly
|
||||||
const getDashboardUrl = (deploymentId?: number) => {
|
const getDashboardUrl = (deploymentId?: number) => {
|
||||||
@ -298,7 +299,8 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
setIsEditModalVisible(false);
|
setIsEditModalVisible(false);
|
||||||
toast.success('Saved', isCustodian ? 'Profile updated successfully' : 'Nickname saved');
|
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) {
|
} catch (err) {
|
||||||
toast.error('Error', 'Failed to save changes.');
|
toast.error('Error', 'Failed to save changes.');
|
||||||
} finally {
|
} finally {
|
||||||
@ -389,7 +391,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
disabled={!beneficiary.avatar || beneficiary.avatar.trim() === '' || beneficiary.avatar.includes('placeholder')}
|
disabled={!beneficiary.avatar || beneficiary.avatar.trim() === '' || beneficiary.avatar.includes('placeholder')}
|
||||||
>
|
>
|
||||||
{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}>
|
<View style={styles.headerAvatar}>
|
||||||
<Text style={styles.headerAvatarText}>
|
<Text style={styles.headerAvatarText}>
|
||||||
@ -526,7 +528,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
disabled={isSavingEdit}
|
disabled={isSavingEdit}
|
||||||
>
|
>
|
||||||
{editForm.avatar ? (
|
{editForm.avatar ? (
|
||||||
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
|
<Image source={{ uri: bustImageCache(editForm.avatar) || undefined }} style={styles.avatarPickerImage} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.avatarPickerPlaceholder}>
|
<View style={styles.avatarPickerPlaceholder}>
|
||||||
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
|
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
|
||||||
@ -625,7 +627,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
{/* Avatar Lightbox */}
|
{/* Avatar Lightbox */}
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
visible={lightboxVisible}
|
visible={lightboxVisible}
|
||||||
imageUri={beneficiary?.avatar || null}
|
imageUri={bustImageCache(beneficiary?.avatar)}
|
||||||
onClose={() => setLightboxVisible(false)}
|
onClose={() => setLightboxVisible(false)}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
} from '@/constants/theme';
|
} from '@/constants/theme';
|
||||||
import type { Beneficiary } from '@/types';
|
import type { Beneficiary } from '@/types';
|
||||||
import { isSubscriptionActive } from '@/services/subscription';
|
import { isSubscriptionActive } from '@/services/subscription';
|
||||||
|
import { bustImageCache } from '@/utils/imageUtils';
|
||||||
|
|
||||||
// Beneficiary card with equipment status support
|
// Beneficiary card with equipment status support
|
||||||
interface BeneficiaryCardProps {
|
interface BeneficiaryCardProps {
|
||||||
@ -115,7 +116,7 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
|||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<View style={styles.avatarWrapper}>
|
<View style={styles.avatarWrapper}>
|
||||||
{hasValidAvatar ? (
|
{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]}>
|
<View style={[styles.avatar, hasNoSubscription && styles.avatarNoSubscription]}>
|
||||||
<Text style={[styles.avatarText, hasNoSubscription && styles.avatarTextNoSubscription]}>
|
<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;
|
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