Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/__tests__/components/imageCache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,25 @@ jest.mock('expo-image', () => ({
}));

jest.mock('@/utils/logger', () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
__esModule: true,
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
appLogger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
default: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));

// ─── Tests ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -59,7 +74,8 @@ describe('Image Cache Integration - Issue #143', () => {
render(<CachedImage uri={testUri} autoPrefetch={true} />);

await waitFor(() => {
expect(prefetchSpy).toHaveBeenCalledWith([testUri]);
// CachedImage passes the negotiated (WebP) URL to prefetchImages
expect(prefetchSpy).toHaveBeenCalledWith([`${testUri}?format=webp`]);
});
});

Expand Down Expand Up @@ -143,15 +159,15 @@ describe('Image Cache Integration - Issue #143', () => {
const { rerender } = render(<CachedImage uri={firstUri} autoPrefetch={true} />);

await waitFor(() => {
expect(prefetchSpy).toHaveBeenCalledWith([firstUri]);
expect(prefetchSpy).toHaveBeenCalledWith([`${firstUri}?format=webp`]);
});

prefetchSpy.mockClear();

rerender(<CachedImage uri={secondUri} autoPrefetch={true} />);

await waitFor(() => {
expect(prefetchSpy).toHaveBeenCalledWith([secondUri]);
expect(prefetchSpy).toHaveBeenCalledWith([`${secondUri}?format=webp`]);
});
});

Expand Down
154 changes: 154 additions & 0 deletions src/__tests__/utils/imageFormat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Platform } from 'react-native';

import {
applyFormatParam,
buildImageAcceptHeader,
getNegotiatedImageUrl,
isImageUrl,
isWebPSupported,
} from '../../utils/imageFormat';

// ─── Helpers ──────────────────────────────────────────────────────────────────

function setPlatform(os: string) {
Object.defineProperty(Platform, 'OS', { value: os, configurable: true });
}

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('imageFormat utility (#267)', () => {
// ─── isWebPSupported ────────────────────────────────────────────────────────

describe('isWebPSupported', () => {
it('returns true on android', () => {
setPlatform('android');
expect(isWebPSupported()).toBe(true);
});

it('returns true on ios', () => {
setPlatform('ios');
expect(isWebPSupported()).toBe(true);
});

it('returns true on web', () => {
setPlatform('web');
expect(isWebPSupported()).toBe(true);
});
});

// ─── buildImageAcceptHeader ─────────────────────────────────────────────────

describe('buildImageAcceptHeader', () => {
it('includes image/webp when WebP is supported', () => {
setPlatform('android');
const header = buildImageAcceptHeader();
expect(header).toContain('image/webp');
});

it('lists webp before png (higher preference)', () => {
setPlatform('ios');
const header = buildImageAcceptHeader();
expect(header.indexOf('image/webp')).toBeLessThan(header.indexOf('image/png'));
});

it('always includes a wildcard fallback', () => {
const header = buildImageAcceptHeader();
expect(header).toContain('image/*');
});
});

// ─── isImageUrl ─────────────────────────────────────────────────────────────

describe('isImageUrl', () => {
it.each([
'https://cdn.example.com/photo.jpg',
'https://cdn.example.com/photo.jpeg',
'https://cdn.example.com/photo.png',
'https://cdn.example.com/photo.webp',
'https://cdn.example.com/photo.gif',
'https://cdn.example.com/photo.avif',
'https://cdn.example.com/photo.svg',
])('returns true for %s', url => {
expect(isImageUrl(url)).toBe(true);
});

it('returns true for CDN URLs with ?format param', () => {
expect(isImageUrl('https://cdn.example.com/asset?format=webp')).toBe(true);
});

it('returns true for /images/ path segment', () => {
expect(isImageUrl('https://api.example.com/images/avatar/123')).toBe(true);
});

it('returns false for non-image URLs', () => {
expect(isImageUrl('https://api.example.com/users/123')).toBe(false);
expect(isImageUrl('https://api.example.com/courses')).toBe(false);
});

it('handles malformed URLs gracefully', () => {
expect(isImageUrl('not-a-url.png')).toBe(true);
expect(isImageUrl('not-a-url')).toBe(false);
});
});

// ─── applyFormatParam ───────────────────────────────────────────────────────

describe('applyFormatParam', () => {
it('appends format param to a plain image URL', () => {
const result = applyFormatParam('https://cdn.example.com/photo.jpg', 'webp');
expect(result).toContain('format=webp');
});

it('replaces an existing format param', () => {
const result = applyFormatParam('https://cdn.example.com/photo.jpg?format=png', 'webp');
expect(result).toContain('format=webp');
expect(result).not.toContain('format=png');
});

it('preserves other query params', () => {
const result = applyFormatParam('https://cdn.example.com/photo.jpg?w=200&h=200', 'webp');
expect(result).toContain('w=200');
expect(result).toContain('h=200');
expect(result).toContain('format=webp');
});

it('passes non-image URLs through unchanged', () => {
const url = 'https://api.example.com/users/123';
expect(applyFormatParam(url, 'webp')).toBe(url);
});

it('handles relative URLs without throwing', () => {
const result = applyFormatParam('/assets/photo.png', 'webp');
expect(result).toContain('format=webp');
});
});

// ─── getNegotiatedImageUrl ──────────────────────────────────────────────────

describe('getNegotiatedImageUrl', () => {
it('appends format=webp on WebP-capable platforms', () => {
setPlatform('android');
const result = getNegotiatedImageUrl('https://cdn.example.com/photo.jpg');
expect(result).toContain('format=webp');
});

it('returns the original URL unchanged for non-image URLs', () => {
setPlatform('android');
const url = 'https://api.example.com/courses';
expect(getNegotiatedImageUrl(url)).toBe(url);
});

it('returns empty string unchanged', () => {
expect(getNegotiatedImageUrl('')).toBe('');
});

it('does not double-append format param on repeated calls', () => {
setPlatform('ios');
const url = 'https://cdn.example.com/photo.png';
const once = getNegotiatedImageUrl(url);
const twice = getNegotiatedImageUrl(once);
// format= should appear exactly once
expect((twice.match(/format=/g) ?? []).length).toBe(1);
});
});
});
27 changes: 14 additions & 13 deletions src/components/ui/CachedImage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image';
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';

import { ImageCache } from '../../utils/imageCache';
import logger from '../../utils/logger';
import { getNegotiatedImageUrl } from '../../utils/imageFormat';
import { logger } from '../../utils/logger';

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -61,47 +63,46 @@ export const CachedImage: React.FC<CachedImageProps> = ({
...expoImageProps
}) => {
const [isLoading, setIsLoading] = useState(!!uri);
const [error, setError] = useState<Error | null>(null);

// Resolve the best format URL for this client once per URI change
const negotiatedUri = uri ? getNegotiatedImageUrl(uri) : uri;

// ─── Prefetch image on mount or when URI changes ──────────────────────────

useEffect(() => {
if (!uri) {
if (!negotiatedUri) {
setIsLoading(false);
return;
}

if (autoPrefetch) {
setIsLoading(true);
ImageCache.prefetchImages([uri])
ImageCache.prefetchImages([negotiatedUri])
.then(() => {
logger.debug(`✅ Image prefetched: ${uri}`);
logger.debug(`✅ Image prefetched: ${negotiatedUri}`);
})
.catch(e => {
logger.warn(`Failed to prefetch image: ${uri}`, e);
setError(e instanceof Error ? e : new Error(String(e)));
logger.warn(`Failed to prefetch image: ${negotiatedUri}`, e);
onLoadError?.(e instanceof Error ? e : new Error(String(e)));
});
}
}, [uri, autoPrefetch, onLoadError]);
}, [negotiatedUri, autoPrefetch, onLoadError]);

// ─── Handle loading complete ───────────────────────────────────────────────

const handleLoadingComplete = () => {
setIsLoading(false);
setError(null);
onLoadComplete?.();
logger.debug(`✅ CachedImage rendered: ${uri}`);
logger.debug(`✅ CachedImage rendered: ${negotiatedUri ?? uri}`);
};

// ─── Handle loading error ──────────────────────────────────────────────────

const handleError = (e: any) => {
const error = e instanceof Error ? e : new Error(String(e));
setIsLoading(false);
setError(error);
onLoadError?.(error);
logger.warn(`Failed to load image: ${uri}`, error);
logger.warn(`Failed to load image: ${negotiatedUri ?? uri}`, error);
};

// ─── Render ────────────────────────────────────────────────────────────────
Expand All @@ -113,7 +114,7 @@ export const CachedImage: React.FC<CachedImageProps> = ({
return (
<View style={[styles.container, style]}>
<ExpoImage
source={{ uri }}
source={{ uri: negotiatedUri ?? uri }}
onLoadingComplete={handleLoadingComplete}
onError={handleError}
accessibilityLabel={alt}
Expand Down
Loading