diff --git a/src/__tests__/components/imageCache.test.tsx b/src/__tests__/components/imageCache.test.tsx
index f3dcc1d1..c7084731 100644
--- a/src/__tests__/components/imageCache.test.tsx
+++ b/src/__tests__/components/imageCache.test.tsx
@@ -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 ────────────────────────────────────────────────────────────────────
@@ -59,7 +74,8 @@ describe('Image Cache Integration - Issue #143', () => {
render();
await waitFor(() => {
- expect(prefetchSpy).toHaveBeenCalledWith([testUri]);
+ // CachedImage passes the negotiated (WebP) URL to prefetchImages
+ expect(prefetchSpy).toHaveBeenCalledWith([`${testUri}?format=webp`]);
});
});
@@ -143,7 +159,7 @@ describe('Image Cache Integration - Issue #143', () => {
const { rerender } = render();
await waitFor(() => {
- expect(prefetchSpy).toHaveBeenCalledWith([firstUri]);
+ expect(prefetchSpy).toHaveBeenCalledWith([`${firstUri}?format=webp`]);
});
prefetchSpy.mockClear();
@@ -151,7 +167,7 @@ describe('Image Cache Integration - Issue #143', () => {
rerender();
await waitFor(() => {
- expect(prefetchSpy).toHaveBeenCalledWith([secondUri]);
+ expect(prefetchSpy).toHaveBeenCalledWith([`${secondUri}?format=webp`]);
});
});
diff --git a/src/__tests__/utils/imageFormat.test.ts b/src/__tests__/utils/imageFormat.test.ts
new file mode 100644
index 00000000..a4456fc8
--- /dev/null
+++ b/src/__tests__/utils/imageFormat.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/src/components/ui/CachedImage.tsx b/src/components/ui/CachedImage.tsx
index 0c306151..7cbb2b58 100644
--- a/src/components/ui/CachedImage.tsx
+++ b/src/components/ui/CachedImage.tsx
@@ -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 ────────────────────────────────────────────────────────────────────
@@ -61,37 +63,37 @@ export const CachedImage: React.FC = ({
...expoImageProps
}) => {
const [isLoading, setIsLoading] = useState(!!uri);
- const [error, setError] = useState(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 ──────────────────────────────────────────────────
@@ -99,9 +101,8 @@ export const CachedImage: React.FC = ({
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 ────────────────────────────────────────────────────────────────
@@ -113,7 +114,7 @@ export const CachedImage: React.FC = ({
return (
new Promise((resolve) => setTimeout(resolve, ms));
+const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
-const getBackoffTime = (retryCount: number) =>
- Math.min(1000 * 2 ** retryCount, 10000);
+const getBackoffTime = (retryCount: number) => Math.min(1000 * 2 ** retryCount, 10000);
// ─── Rate Limit Backoff (Issue #141) ──────────────────────────────────────
@@ -50,13 +51,13 @@ const MAX_RATE_LIMIT_RETRIES = 5;
// ─── Client ────────────────────────────────────────────────────────────────
-const baseURL = getEnv("EXPO_PUBLIC_API_BASE_URL");
+const baseURL = getEnv('EXPO_PUBLIC_API_BASE_URL');
-const apiClient = axios.create({
+const apiClient = createAxios({
baseURL,
timeout: 10000,
headers: {
- "Content-Type": "application/json",
+ 'Content-Type': 'application/json',
},
});
@@ -70,9 +71,7 @@ let refreshQueue: {
}[] = [];
function processRefreshQueue(token: string | null, error: unknown) {
- refreshQueue.forEach(({ resolve, reject }) =>
- token ? resolve(token) : reject(error),
- );
+ refreshQueue.forEach(({ resolve, reject }) => (token ? resolve(token) : reject(error)));
refreshQueue = [];
}
@@ -84,10 +83,15 @@ apiClient.interceptors.request.use(
config._requestStartMs = Date.now();
// Skip adding token for refresh requests
- if (config.url?.includes("/auth/refresh")) {
+ if (config.url?.includes('/auth/refresh')) {
return config;
}
+ // Content negotiation: advertise WebP support for image endpoints
+ if (config.url && isImageUrl(config.url)) {
+ config.headers.Accept = buildImageAcceptHeader();
+ }
+
const token = await getAccessToken();
if (token) {
@@ -96,13 +100,13 @@ apiClient.interceptors.request.use(
return config;
},
- (error) => Promise.reject(error),
+ error => Promise.reject(error)
);
// ─── Response interceptor ───────────────────────────────────────────────────
apiClient.interceptors.response.use(
- (response) => {
+ response => {
// Record successful API call for health metrics
const cfg = response.config as InternalAxiosRequestConfig & { _requestStartMs?: number };
const durationMs = cfg._requestStartMs ? Date.now() - cfg._requestStartMs : 0;
@@ -135,10 +139,10 @@ apiClient.interceptors.response.use(
}
// ── Log non-network errors ────────────────────────────────────────────
- if (error.code === "ERR_NETWORK" || error.message === "Network Error") {
- appLogger.warnSync("API not available (running in offline mode)");
+ if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') {
+ appLogger.warnSync('API not available (running in offline mode)');
} else if (error.response?.status !== 401) {
- appLogger.errorSync("API Error", error as Error, {
+ appLogger.errorSync('API Error', error as Error, {
status: error.response?.status,
data: error.response?.data,
endpoint: originalRequest.url,
@@ -147,7 +151,7 @@ apiClient.interceptors.response.use(
}
// ── Queue network errors for retry ───────────────────────────────────
- if (error.code === "ERR_NETWORK" || error.message === "Network Error") {
+ if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') {
if (originalRequest) {
await requestQueue.addToQueue(originalRequest);
}
@@ -158,7 +162,11 @@ apiClient.interceptors.response.use(
// ─── 401: Token refresh flow ───────────────────────────────────────────
- if (status === 401 && !originalRequest._retry && !originalRequest.url?.includes("/auth/refresh")) {
+ if (
+ status === 401 &&
+ !originalRequest._retry &&
+ !originalRequest.url?.includes('/auth/refresh')
+ ) {
originalRequest._retry = true;
if (isRefreshing) {
@@ -177,17 +185,13 @@ apiClient.interceptors.response.use(
try {
const refreshToken = await getRefreshToken();
- if (!refreshToken) throw new Error("No refresh token");
+ if (!refreshToken) throw new Error('No refresh token');
- const { data } = await apiClient.post("/auth/refresh", {
+ const { data } = await apiClient.post('/auth/refresh', {
refreshToken,
});
- const {
- accessToken,
- refreshToken: newRefresh,
- expiresAt,
- } = data.tokens;
+ const { accessToken, refreshToken: newRefresh, expiresAt } = data.tokens;
await saveTokens(accessToken, newRefresh, expiresAt);
@@ -207,13 +211,13 @@ apiClient.interceptors.response.use(
// ─── 403: Forbidden ────────────────────────────────────────────────────
if (status === 403) {
- appLogger.warnSync("403 Forbidden - access denied", {
+ appLogger.warnSync('403 Forbidden - access denied', {
endpoint: originalRequest.url,
method: originalRequest.method,
});
return Promise.reject({
- message: "You are not allowed to perform this action",
+ message: 'You are not allowed to perform this action',
status: 403,
});
}
@@ -226,7 +230,8 @@ apiClient.interceptors.response.use(
if (originalRequest._retryCount < MAX_RATE_LIMIT_RETRIES) {
originalRequest._retryCount += 1;
const delayIndex = originalRequest._retryCount - 1;
- const delayTime = RATE_LIMIT_DELAYS[delayIndex] || RATE_LIMIT_DELAYS[RATE_LIMIT_DELAYS.length - 1];
+ const delayTime =
+ RATE_LIMIT_DELAYS[delayIndex] || RATE_LIMIT_DELAYS[RATE_LIMIT_DELAYS.length - 1];
// User feedback: Log retry attempt with countdown
appLogger.warnSync(
@@ -245,20 +250,16 @@ apiClient.interceptors.response.use(
}
// Max retries exceeded - user-facing error
- appLogger.errorSync(
- `API Rate Limit: Max retries exceeded`,
- undefined,
- {
- endpoint: originalRequest.url,
- method: originalRequest.method,
- maxRetries: MAX_RATE_LIMIT_RETRIES,
- }
- );
+ appLogger.errorSync(`API Rate Limit: Max retries exceeded`, undefined, {
+ endpoint: originalRequest.url,
+ method: originalRequest.method,
+ maxRetries: MAX_RATE_LIMIT_RETRIES,
+ });
return Promise.reject({
- message: "Too many requests. Please wait a moment and try again.",
+ message: 'Too many requests. Please wait a moment and try again.',
status: 429,
- code: "RATE_LIMIT_EXCEEDED",
+ code: 'RATE_LIMIT_EXCEEDED',
});
}
@@ -278,7 +279,7 @@ apiClient.interceptors.response.use(
}
return Promise.reject({
- message: "Server error. Please try again later.",
+ message: 'Server error. Please try again later.',
status,
});
}
@@ -286,7 +287,7 @@ apiClient.interceptors.response.use(
// ─── Default fallback ──────────────────────────────────────────────────
return Promise.reject(error);
- },
+ }
);
export default apiClient;
diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts
index 0e90c031..1a983aa1 100644
--- a/src/utils/imageCache.ts
+++ b/src/utils/imageCache.ts
@@ -1,10 +1,13 @@
import { Image } from 'expo-image';
-import logger from './logger';
+
+import { getNegotiatedImageUrl } from './imageFormat';
+import { logger } from './logger';
export class ImageCache {
/**
* Prefetches an array of image URLs to memory or disk.
- * Useful for pre-loading images before they are rendered in a fast-scrolling list.
+ * Each URL is resolved to the optimal format for the current client
+ * (WebP on supporting platforms, PNG/JPEG fallback) before prefetching.
*
* @param urls Array of image URLs to prefetch
* @returns A promise that resolves to an array of boolean flags indicating success
@@ -12,11 +15,11 @@ export class ImageCache {
static async prefetchImages(urls: string[]): Promise {
try {
if (!urls || urls.length === 0) return [];
-
- const promises = urls.map(async (url) => {
+
+ const promises = urls.map(async url => {
if (!url) return false;
try {
- return await Image.prefetch(url);
+ return await Image.prefetch(getNegotiatedImageUrl(url));
} catch (e) {
logger.warn(`Failed to prefetch image: ${url}`, e);
return false;
diff --git a/src/utils/imageFormat.ts b/src/utils/imageFormat.ts
new file mode 100644
index 00000000..52827446
--- /dev/null
+++ b/src/utils/imageFormat.ts
@@ -0,0 +1,108 @@
+import { Platform } from 'react-native';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+export type ImageFormat = 'webp' | 'png' | 'jpeg';
+
+// ─── WebP Support Detection ───────────────────────────────────────────────────
+
+/**
+ * Returns true if the current platform natively supports WebP decoding.
+ *
+ * - Android: supported since API 14 (all relevant versions)
+ * - iOS: supported since iOS 14 (expo-image handles this transparently)
+ * - Web: checked via canvas sniff
+ */
+export function isWebPSupported(): boolean {
+ if (Platform.OS === 'android') return true;
+ if (Platform.OS === 'ios') {
+ // expo-image uses SDWebImage which supports WebP on iOS 14+.
+ // React Native's minimum iOS target is 13.4, but expo SDK 50+ targets iOS 15.1+.
+ // Treat as supported; the server can fall back if needed.
+ return true;
+ }
+ // Web: expo-image falls back to
, so rely on browser support.
+ // Modern browsers (Chrome 23+, Firefox 65+, Safari 14+) all support WebP.
+ return true;
+}
+
+// ─── Accept Header ────────────────────────────────────────────────────────────
+
+/**
+ * Builds the HTTP Accept header value for image requests.
+ *
+ * Supporting clients advertise `image/webp` first (higher q-value) so the
+ * server can serve the smaller WebP variant. PNG/JPEG are listed as fallbacks.
+ *
+ * @example
+ * // WebP-capable client:
+ * "image/webp,image/png,image/jpeg,image/*;q=0.8"
+ *
+ * // Fallback client:
+ * "image/png,image/jpeg,image/*;q=0.8"
+ */
+export function buildImageAcceptHeader(): string {
+ if (isWebPSupported()) {
+ return 'image/webp,image/png,image/jpeg,image/*;q=0.8';
+ }
+ return 'image/png,image/jpeg,image/*;q=0.8';
+}
+
+// ─── URL Helpers ──────────────────────────────────────────────────────────────
+
+/**
+ * Returns true if the given URL points to an image resource.
+ * Matches common image extensions and CDN path patterns.
+ */
+export function isImageUrl(url: string): boolean {
+ try {
+ const { pathname, searchParams } = new URL(url);
+ if (/\.(webp|png|jpe?g|gif|avif|svg)(\?|$)/i.test(pathname)) return true;
+ // CDN-style: ?format=webp or /images/ path segment
+ if (searchParams.has('format')) return true;
+ if (/\/images?\//i.test(pathname)) return true;
+ return false;
+ } catch {
+ return /\.(webp|png|jpe?g|gif|avif|svg)(\?|$)/i.test(url);
+ }
+}
+
+/**
+ * Appends (or replaces) a `format` query parameter on an image URL so the
+ * server knows which format the client prefers.
+ *
+ * Only modifies URLs that look like image resources; passes others through
+ * unchanged.
+ */
+export function applyFormatParam(url: string, format: ImageFormat): string {
+ if (!isImageUrl(url)) return url;
+ try {
+ const parsed = new URL(url);
+ parsed.searchParams.set('format', format);
+ return parsed.toString();
+ } catch {
+ // Relative or non-standard URL — append manually
+ const sep = url.includes('?') ? '&' : '?';
+ return `${url}${sep}format=${format}`;
+ }
+}
+
+/**
+ * Returns the preferred image URL for the current client.
+ *
+ * If the client supports WebP, the URL is annotated with `?format=webp` so
+ * the server can serve the optimised variant. Otherwise the original URL is
+ * returned unchanged (server defaults to PNG/JPEG).
+ */
+export function getNegotiatedImageUrl(url: string): string {
+ if (!url) return url;
+ return isWebPSupported() ? applyFormatParam(url, 'webp') : url;
+}
+
+/**
+ * Clears the expo-image format-detection cache (useful in tests).
+ * @internal
+ */
+export function _resetFormatCache(): void {
+ // No persistent cache at the moment; kept for future use and test symmetry.
+}
diff --git a/tsconfig.json b/tsconfig.json
index b287b8aa..6e7a9aa1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,6 +12,7 @@
"@/components/*": ["./src/components/*", "./components/*"],
"@/hooks/*": ["./src/hooks/*", "./hooks/*"],
"@/constants/*": ["./constants/*"],
+ "@/utils/*": ["./src/utils/*"],
"@/*": ["./*"]
},
"lib": ["ESNext", "DOM"],