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"],