Fix avatar caching issues

- Add bustImageCache() at API layer to ensure avatars always have cache-busting timestamps
- Remove redundant bustImageCache() calls from UI components (already handled at API level)
- Add key prop to Image components using avatar URL to force re-render on avatar change
- Add expo-image-manipulator mock to jest.setup.js

This ensures that when users upload new avatars, React Native's Image component
displays the updated image immediately instead of showing cached old versions.

🤖 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 12:51:37 -08:00
parent 70f9a91be1
commit 48ceaeda35
4 changed files with 29 additions and 8 deletions

View File

@ -43,7 +43,6 @@ 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';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
// WebView Dashboard URL - opens specific deployment directly // WebView Dashboard URL - opens specific deployment directly
@ -439,7 +438,11 @@ 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: bustImageCache(beneficiary.avatar) || undefined }} style={styles.headerAvatarImage} /> <Image
key={beneficiary.avatar}
source={{ uri: beneficiary.avatar }}
style={styles.headerAvatarImage}
/>
) : ( ) : (
<View style={styles.headerAvatar}> <View style={styles.headerAvatar}>
<Text style={styles.headerAvatarText}> <Text style={styles.headerAvatarText}>
@ -575,7 +578,11 @@ export default function BeneficiaryDetailScreen() {
disabled={isSavingEdit} disabled={isSavingEdit}
> >
{editForm.avatar ? ( {editForm.avatar ? (
<Image source={{ uri: bustImageCache(editForm.avatar) || undefined }} style={styles.avatarPickerImage} /> <Image
key={editForm.avatar}
source={{ uri: editForm.avatar }}
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} />
@ -674,7 +681,7 @@ export default function BeneficiaryDetailScreen() {
{/* Avatar Lightbox */} {/* Avatar Lightbox */}
<ImageLightbox <ImageLightbox
visible={lightboxVisible} visible={lightboxVisible}
imageUri={bustImageCache(beneficiary?.avatar)} imageUri={beneficiary?.avatar || null}
onClose={() => setLightboxVisible(false)} onClose={() => setLightboxVisible(false)}
/> />
</SafeAreaView> </SafeAreaView>

View File

@ -27,7 +27,6 @@ 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 {
@ -116,7 +115,11 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
{/* Avatar */} {/* Avatar */}
<View style={styles.avatarWrapper}> <View style={styles.avatarWrapper}>
{hasValidAvatar ? ( {hasValidAvatar ? (
<Image source={{ uri: bustImageCache(beneficiary.avatar) || undefined }} style={styles.avatarImage} /> <Image
key={beneficiary.avatar}
source={{ uri: beneficiary.avatar }}
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]}>

View File

@ -61,6 +61,16 @@ jest.mock('expo-image-picker', () => ({
), ),
})); }));
jest.mock('expo-image-manipulator', () => ({
manipulateAsync: jest.fn((uri, actions, options) =>
Promise.resolve({ uri: uri })
),
SaveFormat: {
JPEG: 'jpeg',
PNG: 'png',
},
}));
// Mock AsyncStorage // Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () => ({ jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(), getItem: jest.fn(),

View File

@ -4,6 +4,7 @@ import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as wifiPasswordStore from './wifiPasswordStore'; import * as wifiPasswordStore from './wifiPasswordStore';
import 'react-native-get-random-values'; // Polyfill for crypto.getRandomValues import 'react-native-get-random-values'; // Polyfill for crypto.getRandomValues
import { bustImageCache } from '@/utils/imageUtils';
// Callback for handling unauthorized responses (401) // Callback for handling unauthorized responses (401)
let onUnauthorizedCallback: (() => void) | null = null; let onUnauthorizedCallback: (() => void) | null = null;
@ -733,7 +734,7 @@ class ApiService {
customName: item.customName || null, // User's custom name for this beneficiary customName: item.customName || null, // User's custom name for this beneficiary
displayName: item.displayName || item.customName || item.name || item.email, // Server-provided displayName displayName: item.displayName || item.customName || item.name || item.email, // Server-provided displayName
originalName: item.originalName || item.name, // Original name from beneficiaries table originalName: item.originalName || item.name, // Original name from beneficiaries table
avatar: item.avatarUrl || undefined, // Use uploaded avatar from server avatar: bustImageCache(item.avatarUrl) || undefined, // Use uploaded avatar from server with cache-busting
status: 'offline' as const, status: 'offline' as const,
email: item.email, email: item.email,
address: typeof item.address === 'string' ? item.address : (item.address?.street ? `${item.address.street}, ${item.address.city}` : undefined), address: typeof item.address === 'string' ? item.address : (item.address?.street ? `${item.address.street}, ${item.address.city}` : undefined),
@ -779,7 +780,7 @@ class ApiService {
customName: data.customName || null, // User's custom name for this beneficiary customName: data.customName || null, // User's custom name for this beneficiary
displayName: data.displayName || data.customName || data.name || data.email, // Server-provided displayName displayName: data.displayName || data.customName || data.name || data.email, // Server-provided displayName
originalName: data.originalName || data.name, // Original name from beneficiaries table originalName: data.originalName || data.name, // Original name from beneficiaries table
avatar: data.avatarUrl || undefined, avatar: bustImageCache(data.avatarUrl) || undefined, // Cache-bust avatar URL
status: 'offline' as const, status: 'offline' as const,
email: data.email, email: data.email,
address: typeof data.address === 'string' ? data.address : (data.address?.street ? `${data.address.street}, ${data.address.city}` : undefined), address: typeof data.address === 'string' ? data.address : (data.address?.street ? `${data.address.street}, ${data.address.city}` : undefined),