diff --git a/package-lock.json b/package-lock.json index b449c06d..291cec00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "^12.0.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/drawer": "^7.5.0", "@react-navigation/elements": "^2.6.3", @@ -4110,6 +4111,16 @@ } } }, + "node_modules/@react-native-community/netinfo": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", + "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": ">=0.59" + } + }, "node_modules/@react-native-community/slider": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.2.0.tgz", @@ -22769,6 +22780,12 @@ "invariant": "^2.2.4" } }, + "@react-native-community/netinfo": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", + "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", + "requires": {} + }, "@react-native-community/slider": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.2.0.tgz", diff --git a/package.json b/package.json index 1be03670..fe76406d 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "^12.0.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/drawer": "^7.5.0", "@react-navigation/elements": "^2.6.3", diff --git a/src/__tests__/hooks/usePrefetchImages.test.ts b/src/__tests__/hooks/usePrefetchImages.test.ts index 9fa4ec61..7de40f6b 100644 --- a/src/__tests__/hooks/usePrefetchImages.test.ts +++ b/src/__tests__/hooks/usePrefetchImages.test.ts @@ -7,6 +7,7 @@ import { memoryPressureService } from '../../services/memoryPressureService'; import { useDeviceStore } from '../../store/deviceStore'; import { useSettingsStore } from '../../store/settingsStore'; import { ImageCache } from '../../utils/imageCache'; +import { appLogger } from '../../utils/logger'; // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -18,28 +19,15 @@ jest.mock('expo-image', () => ({ }, })); -// Override the global expo-network mock with controllable fns -const mockGetNetworkState = jest.fn(); -const mockGetCellularGeneration = jest.fn(); - -jest.mock('expo-network', () => ({ - getNetworkStateAsync: (...args: unknown[]) => mockGetNetworkState(...args), - getCellularGenerationAsync: (...args: unknown[]) => mockGetCellularGeneration(...args), - addNetworkStateListener: jest.fn(() => ({ remove: jest.fn() })), - // Use string values that normaliseType() will match via .toUpperCase() - NetworkStateType: { - NONE: 'NONE', - UNKNOWN: 'UNKNOWN', - CELLULAR: 'CELLULAR', - WIFI: 'WIFI', - ETHERNET: 'ETHERNET', - }, - CellularGeneration: { - CELLULAR_4G: 'CELLULAR_4G', - CELLULAR_5G: 'CELLULAR_5G', - CELLULAR_3G: 'CELLULAR_3G', - CELLULAR_2G: 'CELLULAR_2G', - }, +const mockNetInfoFetch = jest.fn(); +let netInfoListener: (state: any) => void; + +jest.mock('@react-native-community/netinfo', () => ({ + fetch: (...args: unknown[]) => mockNetInfoFetch(...args), + addEventListener: jest.fn(cb => { + netInfoListener = cb; + return jest.fn(); + }), })); jest.mock('../../utils/logger', () => ({ @@ -56,20 +44,6 @@ jest.mock('../../utils/logger', () => ({ }, default: { debugSync: jest.fn(), - infoSync: jest.fn(), - warnSync: jest.fn(), - errorSync: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - component: jest.fn(), }, })); @@ -81,9 +55,18 @@ jest.mock('../../services/memoryPressureService', () => ({ // ─── Helpers ────────────────────────────────────────────────────────────────── -const WIFI_STATE = { isConnected: true, isInternetReachable: true, type: 'WIFI' }; -const CELLULAR_STATE = { isConnected: true, isInternetReachable: true, type: 'CELLULAR' }; -const OFFLINE_STATE = { isConnected: false, isInternetReachable: false, type: 'NONE' }; +const WIFI_STATE = { isConnected: true, type: 'wifi', details: { isConnectionExpensive: false } }; +const WIFI_EXPENSIVE_STATE = { + isConnected: true, + type: 'wifi', + details: { isConnectionExpensive: true }, +}; +const CELLULAR_STATE = { + isConnected: true, + type: 'cellular', + details: { isConnectionExpensive: false }, +}; +const OFFLINE_STATE = { isConnected: false, type: 'none', details: null }; const URLS = [ 'https://cdn.example.com/img1.jpg', @@ -104,9 +87,7 @@ describe('usePrefetchImages — issue #233', () => { return { cancel: jest.fn() }; }); - // Default: WiFi connected - mockGetNetworkState.mockResolvedValue(WIFI_STATE); - mockGetCellularGeneration.mockResolvedValue('CELLULAR_4G'); + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); // Ensure stores are in a clean state useSettingsStore.setState({ dataSaverEnabled: false }); @@ -121,7 +102,7 @@ describe('usePrefetchImages — issue #233', () => { jest.useRealTimers(); }); - // ─── 1. Basic smoke test ─────────────────────────────────────────────────── + // ─── Basic smoke test ─────────────────────────────────────────────────── it('returns correct initial state', () => { const { result } = renderHook(() => usePrefetchImages([], { auto: false })); @@ -134,13 +115,15 @@ describe('usePrefetchImages — issue #233', () => { expect(typeof result.current.recordHit).toBe('function'); }); - // ─── 2. WiFi allows prefetch ─────────────────────────────────────────────── - - it('prefetches when on WiFi', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); + it('prefetches when on Wi-Fi normally', async () => { + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true, true]); - renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'moderate' })); + renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'aggressive' })); + + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); @@ -151,140 +134,139 @@ describe('usePrefetchImages — issue #233', () => { }); }); - // ─── 3. 3G blocks prefetch (moderate mode) ──────────────────────────────── + it('completely disables prefetch when isConnectionExpensive is true', async () => { + mockNetInfoFetch.mockResolvedValue(WIFI_EXPENSIVE_STATE); + const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages'); - it('skips prefetch on 3G when aggressiveness is moderate', async () => { - mockGetNetworkState.mockResolvedValue(CELLULAR_STATE); - mockGetCellularGeneration.mockResolvedValue('CELLULAR_3G'); - const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true]); + const { result } = renderHook(() => + usePrefetchImages(URLS, { auto: false, aggressiveness: 'aggressive' }) + ); - renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'moderate' })); + act(() => { + netInfoListener(WIFI_EXPENSIVE_STATE); + }); await act(async () => { - await jest.runAllTimersAsync(); + await result.current.prefetch(URLS); }); - // Wait a tick for async operations to settle - await act(async () => {}); - expect(prefetchSpy).not.toHaveBeenCalled(); + expect(appLogger.debugSync).toHaveBeenCalledWith( + 'usePrefetchImages: skipped — network logic (off or expensive)' + ); }); - // ─── 4. 4G allows prefetch (moderate mode) ──────────────────────────────── + it('caps aggressiveness to conservative when on cellular connection', async () => { + mockNetInfoFetch.mockResolvedValue(CELLULAR_STATE); + const prefetchSpy = jest + .spyOn(ImageCache, 'prefetchImages') + .mockResolvedValue([true, true, true]); - it('prefetches on 4G when aggressiveness is moderate', async () => { - mockGetNetworkState.mockResolvedValue(CELLULAR_STATE); - mockGetCellularGeneration.mockResolvedValue('CELLULAR_4G'); - const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true, true]); + renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'aggressive' })); - renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'moderate' })); + act(() => { + netInfoListener(CELLULAR_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); }); - await waitFor(() => { - expect(prefetchSpy).toHaveBeenCalled(); - }); + expect(prefetchSpy).toHaveBeenCalled(); + + expect(appLogger.debugSync).toHaveBeenCalledWith( + 'usePrefetchImages: prefetch start', + expect.objectContaining({ aggressiveness: 'conservative' }) + ); }); - // ─── 5. Conservative blocks cellular ────────────────────────────────────── + it('skips prefetch when offline', async () => { + mockNetInfoFetch.mockResolvedValue(OFFLINE_STATE); + const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages'); - it('skips prefetch on cellular when aggressiveness is conservative', async () => { - mockGetNetworkState.mockResolvedValue(CELLULAR_STATE); - mockGetCellularGeneration.mockResolvedValue('CELLULAR_4G'); - const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true]); + renderHook(() => usePrefetchImages(URLS, { auto: true })); - renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'conservative' })); + act(() => { + netInfoListener(OFFLINE_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); }); - await act(async () => {}); - expect(prefetchSpy).not.toHaveBeenCalled(); }); - // ─── 6. Conservative allows WiFi ────────────────────────────────────────── + it('cancels active prefetch when network degrades to expensive (abort controller)', async () => { + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); - it('prefetches on WiFi when aggressiveness is conservative', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); - const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true, true]); + let resolvePrefetch: any; + const promise = new Promise(resolve => { + resolvePrefetch = resolve; + }); + jest.spyOn(ImageCache, 'prefetchImages').mockReturnValue(promise as any); - renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'conservative' })); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); - await act(async () => { - await jest.runAllTimersAsync(); + const { result } = renderHook(() => usePrefetchImages(URLS, { auto: false })); + + act(() => { + netInfoListener(WIFI_STATE); }); - await waitFor(() => { - expect(prefetchSpy).toHaveBeenCalled(); + let prefetchPromise: Promise; + act(() => { + prefetchPromise = result.current.prefetch(URLS); }); - }); - // ─── 7. Offline skips prefetch ──────────────────────────────────────────── + expect(result.current.isPrefetching).toBe(true); - it('skips prefetch when offline', async () => { - mockGetNetworkState.mockResolvedValue(OFFLINE_STATE); - const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([]); + act(() => { + netInfoListener(WIFI_EXPENSIVE_STATE); + }); - renderHook(() => usePrefetchImages(URLS, { auto: true })); + expect(abortSpy).toHaveBeenCalled(); - await act(async () => { - await jest.runAllTimersAsync(); + act(() => { + resolvePrefetch([true, true, true]); }); - await act(async () => {}); + const res = await prefetchPromise!; - expect(prefetchSpy).not.toHaveBeenCalled(); + expect(res).toEqual([]); + expect(appLogger.debugSync).toHaveBeenCalledWith( + 'usePrefetchImages: aborted due to network downgrade' + ); }); - // ─── 8. 'off' aggressiveness skips prefetch ─────────────────────────────── - - it('skips prefetch when aggressiveness is off', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); - const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([]); + it('skips prefetch when aggressiveness is explicitly off', async () => { + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); + const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages'); renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'off' })); - await act(async () => { - await jest.runAllTimersAsync(); + act(() => { + netInfoListener(WIFI_STATE); }); - await act(async () => {}); - - expect(prefetchSpy).not.toHaveBeenCalled(); - }); - - // ─── 9. Slow network (network check throws) ─────────────────────────────── - - it('handles network check failure gracefully (no crash)', async () => { - mockGetNetworkState.mockRejectedValue(new Error('Network module unavailable')); - const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([]); - - renderHook(() => usePrefetchImages(URLS, { auto: true })); - await act(async () => { await jest.runAllTimersAsync(); }); - await act(async () => {}); - - // Should not prefetch and should not throw expect(prefetchSpy).not.toHaveBeenCalled(); }); - // ─── 10. Respects limit ─────────────────────────────────────────────────── - it('respects the limit option (caps at provided limit, max 10)', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); const many = Array.from({ length: 20 }, (_, i) => `https://cdn.example.com/img${i}.jpg`); const prefetchSpy = jest .spyOn(ImageCache, 'prefetchImages') .mockResolvedValue(Array(3).fill(true)); renderHook(() => usePrefetchImages(many, { auto: true, limit: 3 })); + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); @@ -297,16 +279,17 @@ describe('usePrefetchImages — issue #233', () => { }); }); - // ─── 11. Max limit clamped to 10 ───────────────────────────────────────── - it('clamps limit to MAX_LIMIT (10) even if a higher value is passed', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); const many = Array.from({ length: 20 }, (_, i) => `https://cdn.example.com/img${i}.jpg`); const prefetchSpy = jest .spyOn(ImageCache, 'prefetchImages') .mockResolvedValue(Array(10).fill(true)); renderHook(() => usePrefetchImages(many, { auto: true, limit: 50 })); + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); @@ -319,58 +302,57 @@ describe('usePrefetchImages — issue #233', () => { }); }); - // ─── 12. Data saver skips prefetch ─────────────────────────────────────── - it('skips prefetch when data saver is enabled', async () => { useSettingsStore.setState({ dataSaverEnabled: true }); const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([]); renderHook(() => usePrefetchImages(URLS, { auto: true })); + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); }); - await act(async () => {}); - expect(prefetchSpy).not.toHaveBeenCalled(); useSettingsStore.setState({ dataSaverEnabled: false }); }); - // ─── 13. Low battery skips prefetch ────────────────────────────────────── - it('skips prefetch when battery is low', async () => { useDeviceStore.setState({ isLowBattery: true }); const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([]); renderHook(() => usePrefetchImages(URLS, { auto: true })); + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); }); - await act(async () => {}); - expect(prefetchSpy).not.toHaveBeenCalled(); useDeviceStore.setState({ isLowBattery: false }); }); - // ─── 14. Hit rate tracking ──────────────────────────────────────────────── - it('tracks hit rate when recordHit is called for a prefetched URL', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true, true, true]); const { result } = renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'moderate' }) ); + act(() => { + netInfoListener(WIFI_STATE); + }); + await act(async () => { await jest.runAllTimersAsync(); }); await waitFor(() => { - // At this point prefetch ran; hitRate still 0 (no hits recorded yet) expect(result.current.hitRate).toBe(0); }); @@ -392,13 +374,14 @@ describe('usePrefetchImages — issue #233', () => { expect(result.current.hitRate).toBe(0); }); - // ─── 15. Manual prefetch with manual auto: false ────────────────────────── - it('manual prefetch works on WiFi', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true, true]); const { result } = renderHook(() => usePrefetchImages([], { auto: false })); + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await result.current.prefetch(URLS.slice(0, 2)); @@ -407,60 +390,36 @@ describe('usePrefetchImages — issue #233', () => { expect(prefetchSpy).toHaveBeenCalledWith(URLS.slice(0, 2)); }); - it('manual prefetch is blocked on 3G moderate', async () => { - mockGetNetworkState.mockResolvedValue(CELLULAR_STATE); - mockGetCellularGeneration.mockResolvedValue('CELLULAR_3G'); - const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([]); - - const { result } = renderHook(() => - usePrefetchImages([], { auto: false, aggressiveness: 'moderate' }) - ); - - await act(async () => { - await result.current.prefetch(URLS); - }); - - expect(prefetchSpy).not.toHaveBeenCalled(); - }); - - // ─── 16. Reads aggressiveness from AsyncStorage ─────────────────────────── - - it('reads prefetch_aggressiveness from AsyncStorage and applies it to manual prefetch', async () => { - // Stored value is 'conservative' - (AsyncStorage.getItem as jest.Mock).mockResolvedValue('conservative'); + it('reads prefetch_aggressiveness from AsyncStorage and applies it', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('off'); const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([]); - // auto: false so the prefetch doesn't fire before AsyncStorage resolves const { result } = renderHook(() => usePrefetchImages(URLS, { auto: false })); + act(() => { + netInfoListener(WIFI_STATE); + }); - // Flush microtasks so the AsyncStorage effect has time to load and - // update storedAggressiveness to 'conservative' await act(async () => { await Promise.resolve(); - await Promise.resolve(); // two ticks to cover the setState update + await Promise.resolve(); }); - // Now set up network as 4G cellular — conservative mode should block this - mockGetNetworkState.mockResolvedValue(CELLULAR_STATE); - mockGetCellularGeneration.mockResolvedValue('CELLULAR_4G'); - await act(async () => { await result.current.prefetch(URLS); }); - // Conservative + cellular (even 4G) → no prefetch expect(prefetchSpy).not.toHaveBeenCalled(); }); - // ─── 17. Explicit prop overrides stored preference ──────────────────────── - it('explicit aggressiveness prop overrides AsyncStorage value', async () => { (AsyncStorage.getItem as jest.Mock).mockResolvedValue('off'); - mockGetNetworkState.mockResolvedValue(WIFI_STATE); + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true]); - // Explicit 'conservative' prop should override the 'off' stored value - renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'conservative' })); + renderHook(() => usePrefetchImages(URLS, { auto: true, aggressiveness: 'moderate' })); + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); @@ -471,19 +430,18 @@ describe('usePrefetchImages — issue #233', () => { }); }); - // ─── 18. clearCache resets hit rate ────────────────────────────────────── - it('clearCache resets hit rate and prefetch set', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true]); jest.spyOn(ImageCache, 'clearCache').mockResolvedValue(); - // Use auto: false so we control timing with manual prefetch (avoids async act loops) const { result } = renderHook(() => usePrefetchImages(URLS.slice(0, 1), { auto: false, aggressiveness: 'moderate' }) ); + act(() => { + netInfoListener(WIFI_STATE); + }); - // Manually prefetch so the URL is registered in prefetchedRef await act(async () => { await result.current.prefetch(URLS.slice(0, 1)); }); @@ -502,30 +460,30 @@ describe('usePrefetchImages — issue #233', () => { expect(result.current.hitRate).toBe(0); }); - // ─── 19. Memory pressure skips prefetch ────────────────────────────────── - it('skips prefetch under memory pressure', async () => { (memoryPressureService.isUnderPressure as jest.Mock).mockReturnValue(true); const prefetchSpy = jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([]); renderHook(() => usePrefetchImages(URLS, { auto: true })); + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); }); - await act(async () => {}); - expect(prefetchSpy).not.toHaveBeenCalled(); }); - // ─── 20. Uses InteractionManager for auto mode ──────────────────────────── - it('uses InteractionManager.runAfterInteractions for auto mode', async () => { - mockGetNetworkState.mockResolvedValue(WIFI_STATE); + mockNetInfoFetch.mockResolvedValue(WIFI_STATE); jest.spyOn(ImageCache, 'prefetchImages').mockResolvedValue([true]); renderHook(() => usePrefetchImages(URLS, { auto: true })); + act(() => { + netInfoListener(WIFI_STATE); + }); await act(async () => { await jest.runAllTimersAsync(); diff --git a/src/hooks/usePrefetchImages.ts b/src/hooks/usePrefetchImages.ts index 10da0b40..a4334bc1 100644 --- a/src/hooks/usePrefetchImages.ts +++ b/src/hooks/usePrefetchImages.ts @@ -1,5 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { getCellularGenerationAsync, getNetworkStateAsync, NetworkStateType } from 'expo-network'; +import NetInfo, { NetInfoState } from '@react-native-community/netinfo'; import { useCallback, useEffect, useRef, useState } from 'react'; import { InteractionManager } from 'react-native'; @@ -19,10 +19,7 @@ const MAX_LIMIT = 10; // ─── Types ──────────────────────────────────────────────────────────────────── /** Controls which network conditions allow prefetching */ -export type PrefetchAggressiveness = - | 'conservative' // WiFi only - | 'moderate' // WiFi or 4G+ - | 'off'; // Never prefetch +export type PrefetchAggressiveness = 'aggressive' | 'moderate' | 'conservative' | 'off'; interface UsePrefetchImagesOptions { /** Whether to automatically prefetch on mount (via InteractionManager) */ @@ -57,62 +54,31 @@ interface UsePrefetchImagesReturn { recordHit: (url: string) => void; } -// ─── Network gate ───────────────────────────────────────────────────────────── - /** - * Converts the network type field (which may be a string, number, or enum - * depending on the expo-network version) to a normalised uppercase string so - * comparisons are robust across mock and real environments. + * Calculates the effective aggressiveness level factoring in current real-world + * network parameters retrieved via NetInfo. */ -function normaliseType(type: unknown): string { - return (type ?? '').toString().toUpperCase(); -} - -async function networkAllowsPrefetch( - aggressiveness: PrefetchAggressiveness -): Promise<{ allowed: boolean; reason?: string }> { - if (aggressiveness === 'off') { - return { allowed: false, reason: 'prefetch-disabled' }; +function determineEffectiveAggressiveness( + baseAggressiveness: PrefetchAggressiveness, + netState: NetInfoState | null +): PrefetchAggressiveness { + if (baseAggressiveness === 'off' || !netState || !netState.isConnected) { + return 'off'; } - try { - const state = await getNetworkStateAsync(); - - if (!state.isConnected) { - return { allowed: false, reason: 'offline' }; - } - - const typeStr = normaliseType(state.type); - - // WiFi is always acceptable for both modes - if (typeStr === 'WIFI' || typeStr === String(NetworkStateType.WIFI)) { - return { allowed: true }; - } - - if (aggressiveness === 'conservative') { - return { allowed: false, reason: 'conservative:not-wifi' }; - } - - // moderate: also allow 4G / 5G cellular - const isCellular = typeStr === 'CELLULAR' || typeStr === String(NetworkStateType.CELLULAR); - if (isCellular) { - try { - const gen = await getCellularGenerationAsync(); - const genStr = (gen ?? '').toString().toUpperCase().replace('CELLULAR_', ''); - // Accept both 'CELLULAR_4G'→'4G' and plain '4G' or '4g' - if (genStr === '4G' || genStr === '5G') { - return { allowed: true }; - } - return { allowed: false, reason: `moderate:below-4g(${genStr})` }; - } catch { - return { allowed: false, reason: 'moderate:cellular-gen-unknown' }; - } - } + if ( + netState.details && + 'isConnectionExpensive' in netState.details && + netState.details.isConnectionExpensive + ) { + return 'off'; + } - return { allowed: false, reason: `moderate:type-${typeStr}` }; - } catch { - return { allowed: false, reason: 'network-check-failed' }; + if (netState.type === 'cellular') { + return 'conservative'; } + + return baseAggressiveness; } // ─── Hook ────────────────────────────────────────────────────────────────────── @@ -122,18 +88,10 @@ async function networkAllowsPrefetch( * * Provides efficient image prefetching for performance optimization. * - Respects data saver, low battery, and memory pressure. - * - Network-gated: only prefetches on WiFi (conservative) or WiFi+4G (moderate). + * - Dynamically updates on network changes using NetInfo. + * - Caps cellular prefetch to conservative. Disables on metered connections. * - Auto mode uses InteractionManager to avoid blocking animations. * - Tracks hit rate: call `recordHit(url)` whenever a prefetched image is rendered. - * - * @example - * ```tsx - * const { isPrefetching, hitRate, recordHit } = usePrefetchImages(thumbnailUrls, { - * auto: true, - * limit: 10, - * aggressiveness: 'moderate', - * }); - * ``` */ export function usePrefetchImages( urls: (string | null | undefined)[], @@ -157,15 +115,36 @@ export function usePrefetchImages( const [failedUrls, setFailedUrls] = useState([]); const [storedAggressiveness, setStoredAggressiveness] = useState(DEFAULT_AGGRESSIVENESS); + const [netState, setNetState] = useState(null); // Hit-rate tracking in refs (mutations must not trigger re-renders) const prefetchedRef = useRef>(new Set()); const hitsRef = useRef(0); const [hitRate, setHitRate] = useState(0); + const abortControllerRef = useRef(null); + + useEffect(() => { + NetInfo.fetch().then(setNetState); + + const unsubscribe = NetInfo.addEventListener((state: any) => { + setNetState(state); + + const currentAggressive = determineEffectiveAggressiveness( + aggressivenessProp ?? storedAggressiveness, + state + ); + if (currentAggressive === 'off' && isPrefetching) { + abortControllerRef.current?.abort(); + } + }); + + return unsubscribe; + }, [aggressivenessProp, storedAggressiveness, isPrefetching]); // ─── Effective settings ────────────────────────────────────────────────────── - const effectiveAggressiveness = aggressivenessProp ?? storedAggressiveness; + const baseAggressiveness = aggressivenessProp ?? storedAggressiveness; + const runtimeAggressiveness = determineEffectiveAggressiveness(baseAggressiveness, netState); const effectiveLimit = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT); // ─── Load stored aggressiveness preference on init ─────────────────────────── @@ -174,8 +153,8 @@ export function usePrefetchImages( if (aggressivenessProp !== undefined) return; // explicit prop wins AsyncStorage.getItem(PREFETCH_AGGRESSIVENESS_KEY) .then(value => { - if (value === 'conservative' || value === 'moderate' || value === 'off') { - setStoredAggressiveness(value); + if (value && ['conservative', 'moderate', 'aggressive', 'off'].includes(value)) { + setStoredAggressiveness(value as PrefetchAggressiveness); } }) .catch(() => {}); @@ -197,25 +176,31 @@ export function usePrefetchImages( return []; } - const { allowed, reason } = await networkAllowsPrefetch(effectiveAggressiveness); - if (!allowed) { - appLogger.debugSync(`usePrefetchImages: skipped — network (${reason})`); + if (runtimeAggressiveness === 'off') { + appLogger.debugSync('usePrefetchImages: skipped — network logic (off or expensive)'); return []; } const validUrls = (toFetch.filter(Boolean) as string[]).slice(0, effectiveLimit); if (validUrls.length === 0) return []; + abortControllerRef.current = new AbortController(); + try { setIsPrefetching(true); appLogger.debugSync('usePrefetchImages: prefetch start', { count: validUrls.length, - aggressiveness: effectiveAggressiveness, + aggressiveness: runtimeAggressiveness, }); const results = await ImageCache.prefetchImages(validUrls); + if (abortControllerRef.current.signal.aborted) { + appLogger.debugSync('usePrefetchImages: aborted due to network downgrade'); + return []; + } + // Register which URLs we prefetched so recordHit can track them validUrls.forEach(url => prefetchedRef.current.add(url)); @@ -239,16 +224,15 @@ export function usePrefetchImages( setIsPrefetching(false); } }, - - [dataSaverEnabled, isLowBattery, effectiveAggressiveness, effectiveLimit, onComplete, onError] + [dataSaverEnabled, isLowBattery, runtimeAggressiveness, effectiveLimit, onComplete, onError] ); // ─── Auto-prefetch via InteractionManager ──────────────────────────────────── useEffect(() => { if (!auto) return; - if (dataSaverEnabled || isLowBattery) return; - if (memoryPressureService.isUnderPressure()) return; + if (dataSaverEnabled || isLowBattery || memoryPressureService.isUnderPressure()) return; + if (runtimeAggressiveness === 'off') return; const validUrls = (urls.filter(Boolean) as string[]).slice(0, effectiveLimit); if (validUrls.length === 0) return; @@ -282,7 +266,7 @@ export function usePrefetchImages( auto, delay, effectiveLimit, - effectiveAggressiveness, + runtimeAggressiveness, prefetch, dataSaverEnabled, isLowBattery,