From 1f6f91e4eacde937565921d176bf583656b2cc3f Mon Sep 17 00:00:00 2001 From: Femi John Date: Sat, 30 May 2026 07:54:51 +0100 Subject: [PATCH] feat: implement AppLifecycle hook to manage sync, socket, and request queue states while updating Skeleton to pause animations in background. --- App.tsx | 11 +- src/components/ui/Skeleton.tsx | 36 +++++- src/hooks/index.ts | 1 + src/hooks/useAppLifecycle.ts | 58 +++++++++ src/services/api/requestQueue.ts | 33 +++++- tests/components/Skeleton.test.tsx | 71 +++++++++++ tests/hooks/useAppLifecycle.test.ts | 149 ++++++++++++++++++++++++ tests/services/api/requestQueue.test.ts | 93 +++++++++++++++ tests/services/syncService.test.ts | 78 +++++++++++++ 9 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 src/hooks/useAppLifecycle.ts create mode 100644 tests/hooks/useAppLifecycle.test.ts create mode 100644 tests/services/api/requestQueue.test.ts create mode 100644 tests/services/syncService.test.ts diff --git a/App.tsx b/App.tsx index 70ae326..ad2a501 100644 --- a/App.tsx +++ b/App.tsx @@ -9,18 +9,18 @@ import * as Font from 'expo-font'; import * as SplashScreen from 'expo-splash-screen'; import { ErrorBoundary } from './src/components/common/ErrorBoundary'; import { initializeLogging } from './src/config/logging'; -import { AuthProvider, useAdaptiveTheme } from './src/hooks'; +import { AuthProvider, useAdaptiveTheme, useAppLifecycle } from './src/hooks'; import AppNavigator from './src/navigation/AppNavigator'; import { setupNotificationNavigation } from './src/navigation/linking'; import { apiClient } from './src/services/api'; -import { crashReportingService } from './src/services/cashReporting'; +import { crashReportingService } from './src/services/crashReporting'; import { mobileAuthService } from './src/services/mobileAuth'; import { addNotificationReceivedListener, getLastNotificationResponse, removeNotificationListener, } from './src/services/pushNotifications'; -import { requestQueue } from './src/services/requestQueue'; +import { requestQueue } from './src/services/api/requestQueue'; import socketService from './src/services/socket'; import syncService from './src/services/syncService'; import { useAppStore } from './src/store'; @@ -59,6 +59,10 @@ const App = () => { const theme = useAppStore((state) => state.theme); useAdaptiveTheme(); + // Pause animations, stop timers, and defer syncs when app is backgrounded; + // resume everything when it returns to the foreground. + useAppLifecycle(); + const appStateRef = useRef(AppState.currentState); const [appIsReady, setAppIsReady] = React.useState(false); @@ -153,6 +157,7 @@ const App = () => { return () => { socketService.disconnect(); syncService.stopAutoSync(); + requestQueue.stopMonitoring(); notificationCleanup(); removeNotificationListener(subscription); // Clean up the unhandled rejection handler diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx index 552bc64..e942061 100644 --- a/src/components/ui/Skeleton.tsx +++ b/src/components/ui/Skeleton.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { Animated, DimensionValue, StyleSheet, View, ViewStyle } from 'react-native'; +import { Animated, AppState, AppStateStatus, DimensionValue, StyleSheet, View, ViewStyle } from 'react-native'; import { useAdaptiveFrameRate } from '../../hooks/useAdaptiveFrameRate'; /** @@ -26,15 +26,19 @@ export const Skeleton: React.FC = ({ style, }) => { const pulseAnim = useRef(new Animated.Value(0.3)).current; + const animationRef = useRef(null); const { durationMultiplier } = useAdaptiveFrameRate(); - useEffect(() => { + const startAnimation = () => { + // Stop any existing animation before starting a new one + animationRef.current?.stop(); + const sharedAnimationConfig = { duration: 1000 * durationMultiplier, useNativeDriver: true, }; - Animated.loop( + animationRef.current = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { ...sharedAnimationConfig, @@ -45,8 +49,30 @@ export const Skeleton: React.FC = ({ toValue: 0.3, }), ]) - ).start(); - }, [pulseAnim, durationMultiplier]); + ); + animationRef.current.start(); + }; + + useEffect(() => { + startAnimation(); + + const handleAppStateChange = (nextState: AppStateStatus) => { + if (nextState === 'active') { + startAnimation(); + } else { + // background or inactive — pause the shimmer + animationRef.current?.stop(); + } + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + animationRef.current?.stop(); + subscription.remove(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [durationMultiplier]); const skeletonStyle: ViewStyle = { width: width, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2da5ae0..ea4fa6f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -33,3 +33,4 @@ export { useOptimizedVideoGestures, OptimizedVideoGesturesView } from './useOpti export * from './useDebounce'; export * from './usePredictivePreload'; +export * from './useAppLifecycle'; diff --git a/src/hooks/useAppLifecycle.ts b/src/hooks/useAppLifecycle.ts new file mode 100644 index 0000000..92225ce --- /dev/null +++ b/src/hooks/useAppLifecycle.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; +import { requestQueue } from '../services/api/requestQueue'; +import socketService from '../services/socket'; +import syncService from '../services/syncService'; +import { appLogger } from '../utils/logger'; + +/** + * Centralises background/foreground lifecycle management. + * + * When the app moves to the background or becomes inactive: + * • The auto-sync interval is stopped. + * • The request-queue monitoring interval is stopped. + * • The WebSocket connection is disconnected. + * + * When the app returns to the foreground: + * • Auto-sync is restarted. + * • Request-queue monitoring is resumed (using the previously stored api client). + * • The WebSocket is reconnected. + * + * Skeleton shimmer animations are paused/resumed independently inside the + * Skeleton component itself via its own AppState listener. + */ +export function useAppLifecycle(): void { + const appStateRef = useRef(AppState.currentState); + + useEffect(() => { + const handleAppStateChange = (nextState: AppStateStatus) => { + const prevState = appStateRef.current; + appStateRef.current = nextState; + + const goingToBackground = + prevState === 'active' && (nextState === 'background' || nextState === 'inactive'); + const returningToForeground = + (prevState === 'background' || prevState === 'inactive') && nextState === 'active'; + + if (goingToBackground) { + appLogger.infoSync('[Lifecycle] App backgrounded — pausing timers & socket'); + syncService.stopAutoSync(); + requestQueue.stopMonitoring(); + socketService.disconnect(); + } + + if (returningToForeground) { + appLogger.infoSync('[Lifecycle] App foregrounded — resuming timers & socket'); + syncService.startAutoSync(); + requestQueue.resumeMonitoring(); + socketService.connect(); + } + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + subscription.remove(); + }; + }, []); +} diff --git a/src/services/api/requestQueue.ts b/src/services/api/requestQueue.ts index 33534b5..fc740d3 100644 --- a/src/services/api/requestQueue.ts +++ b/src/services/api/requestQueue.ts @@ -18,6 +18,8 @@ class RequestQueue { private readonly MAX_RETRIES = 3; private isProcessing = false; private listeners: ((count: number) => void)[] = []; + private monitoringIntervalId: ReturnType | null = null; + private apiClientRef: any = null; /** * Add a failed request to the queue @@ -118,10 +120,15 @@ class RequestQueue { } /** - * Start monitoring network and processing queue + * Start monitoring network and processing queue. + * Safe to call multiple times — no-ops if already running. */ startMonitoring(apiClient: any): void { - const interval = setInterval(async () => { + if (this.monitoringIntervalId !== null) { + return; // already running + } + this.apiClientRef = apiClient; + this.monitoringIntervalId = setInterval(async () => { await this.processQueue(apiClient); }, 10000); // Check every 10 seconds @@ -129,6 +136,28 @@ class RequestQueue { this.processQueue(apiClient); } + /** + * Stop network-monitoring interval. + * Called when the app moves to the background to save battery. + */ + stopMonitoring(): void { + if (this.monitoringIntervalId !== null) { + clearInterval(this.monitoringIntervalId); + this.monitoringIntervalId = null; + logger.info('Request queue monitoring stopped'); + } + } + + /** + * Resume monitoring after returning to the foreground. + * Uses the apiClient that was passed to the last startMonitoring call. + */ + resumeMonitoring(): void { + if (this.apiClientRef) { + this.startMonitoring(this.apiClientRef); + } + } + /** * Get pending requests count */ diff --git a/tests/components/Skeleton.test.tsx b/tests/components/Skeleton.test.tsx index dbab7a4..3e78e89 100644 --- a/tests/components/Skeleton.test.tsx +++ b/tests/components/Skeleton.test.tsx @@ -117,3 +117,74 @@ describe('Skeleton', () => { }); }); }); + +// ── AppState lifecycle ──────────────────────────────────────────────────────── + +import { AppState } from 'react-native'; + +describe('Skeleton — AppState animation lifecycle', () => { + // Grab the mocked Animated internals provided by jest.setup.js + const { Animated: MockAnimated } = require('react-native'); + + let stopMock: jest.Mock; + let loopInstance: { start: jest.Mock; stop: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Each Animated.loop() call returns a fresh controllable handle + stopMock = jest.fn(); + loopInstance = { start: jest.fn(), stop: stopMock }; + MockAnimated.loop.mockReturnValue(loopInstance); + }); + + it('starts the animation on mount', () => { + render(); + expect(MockAnimated.loop).toHaveBeenCalled(); + expect(loopInstance.start).toHaveBeenCalled(); + }); + + it('stops the animation when the app goes to background', () => { + render(); + + // Simulate AppState → background + const [[, handler]] = (AppState.addEventListener as jest.Mock).mock.calls; + handler('background'); + + expect(stopMock).toHaveBeenCalled(); + }); + + it('stops the animation when the app becomes inactive', () => { + render(); + + const [[, handler]] = (AppState.addEventListener as jest.Mock).mock.calls; + handler('inactive'); + + expect(stopMock).toHaveBeenCalled(); + }); + + it('restarts the animation when the app returns to the foreground', () => { + render(); + const callsBefore = MockAnimated.loop.mock.calls.length; + + const [[, handler]] = (AppState.addEventListener as jest.Mock).mock.calls; + handler('background'); + handler('active'); + + // A new loop should have been created when returning to active + expect(MockAnimated.loop.mock.calls.length).toBeGreaterThan(callsBefore); + expect(loopInstance.start).toHaveBeenCalledTimes(2); // mount + foreground + }); + + it('stops the animation and removes the AppState listener on unmount', () => { + const mockRemove = jest.fn(); + (AppState.addEventListener as jest.Mock).mockReturnValue({ remove: mockRemove }); + + const { unmount } = render(); + unmount(); + + expect(stopMock).toHaveBeenCalled(); + expect(mockRemove).toHaveBeenCalled(); + }); +}); + diff --git a/tests/hooks/useAppLifecycle.test.ts b/tests/hooks/useAppLifecycle.test.ts new file mode 100644 index 0000000..7a3346a --- /dev/null +++ b/tests/hooks/useAppLifecycle.test.ts @@ -0,0 +1,149 @@ +import { renderHook } from '@testing-library/react-native'; +import { AppState } from 'react-native'; +import { useAppLifecycle } from '../../src/hooks/useAppLifecycle'; + +// ── Service mocks ───────────────────────────────────────────────────────────── +jest.mock('../../src/services/syncService', () => ({ + __esModule: true, + default: { + startAutoSync: jest.fn(), + stopAutoSync: jest.fn(), + }, +})); + +jest.mock('../../src/services/api/requestQueue', () => ({ + __esModule: true, + requestQueue: { + stopMonitoring: jest.fn(), + resumeMonitoring: jest.fn(), + }, +})); + +jest.mock('../../src/services/socket', () => ({ + __esModule: true, + default: { + connect: jest.fn(), + disconnect: jest.fn(), + }, +})); + +jest.mock('../../src/utils/logger', () => ({ + appLogger: { infoSync: jest.fn(), errorSync: jest.fn() }, +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── +import syncService from '../../src/services/syncService'; +import { requestQueue } from '../../src/services/api/requestQueue'; +import socketService from '../../src/services/socket'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +/** + * Returns the AppState change handler registered by the hook. + * Relies on jest.setup.js mocking AppState.addEventListener. + */ +function getAppStateHandler(): (state: string) => void { + const calls = (AppState.addEventListener as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + return lastCall[1]; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── +describe('useAppLifecycle', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset currentState so all tests start from 'active' + (AppState as any).currentState = 'active'; + }); + + it('registers an AppState listener on mount', () => { + renderHook(() => useAppLifecycle()); + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('removes the AppState listener on unmount', () => { + const mockRemove = jest.fn(); + (AppState.addEventListener as jest.Mock).mockReturnValueOnce({ remove: mockRemove }); + + const { unmount } = renderHook(() => useAppLifecycle()); + unmount(); + + expect(mockRemove).toHaveBeenCalledTimes(1); + }); + + describe('active → background', () => { + it('stops the sync interval', () => { + renderHook(() => useAppLifecycle()); + getAppStateHandler()('background'); + expect(syncService.stopAutoSync).toHaveBeenCalledTimes(1); + }); + + it('stops request-queue monitoring', () => { + renderHook(() => useAppLifecycle()); + getAppStateHandler()('background'); + expect(requestQueue.stopMonitoring).toHaveBeenCalledTimes(1); + }); + + it('disconnects the socket', () => { + renderHook(() => useAppLifecycle()); + getAppStateHandler()('background'); + expect(socketService.disconnect).toHaveBeenCalledTimes(1); + }); + }); + + describe('active → inactive', () => { + it('also pauses timers for inactive state', () => { + renderHook(() => useAppLifecycle()); + getAppStateHandler()('inactive'); + expect(syncService.stopAutoSync).toHaveBeenCalledTimes(1); + expect(requestQueue.stopMonitoring).toHaveBeenCalledTimes(1); + expect(socketService.disconnect).toHaveBeenCalledTimes(1); + }); + }); + + describe('background → active', () => { + it('restarts the sync interval', () => { + renderHook(() => useAppLifecycle()); + const handler = getAppStateHandler(); + + handler('background'); // go to background first + jest.clearAllMocks(); + (AppState as any).currentState = 'background'; + + handler('active'); // return to foreground + expect(syncService.startAutoSync).toHaveBeenCalledTimes(1); + }); + + it('resumes request-queue monitoring', () => { + renderHook(() => useAppLifecycle()); + const handler = getAppStateHandler(); + + handler('background'); + jest.clearAllMocks(); + (AppState as any).currentState = 'background'; + + handler('active'); + expect(requestQueue.resumeMonitoring).toHaveBeenCalledTimes(1); + }); + + it('reconnects the socket', () => { + renderHook(() => useAppLifecycle()); + const handler = getAppStateHandler(); + + handler('background'); + jest.clearAllMocks(); + (AppState as any).currentState = 'background'; + + handler('active'); + expect(socketService.connect).toHaveBeenCalledTimes(1); + }); + }); + + describe('active → active (no-op)', () => { + it('does not trigger pause or resume when already active', () => { + renderHook(() => useAppLifecycle()); + getAppStateHandler()('active'); // still active — should be no-op + expect(syncService.stopAutoSync).not.toHaveBeenCalled(); + expect(syncService.startAutoSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/services/api/requestQueue.test.ts b/tests/services/api/requestQueue.test.ts new file mode 100644 index 0000000..f5c45e9 --- /dev/null +++ b/tests/services/api/requestQueue.test.ts @@ -0,0 +1,93 @@ +import requestQueue from '../../../src/services/api/requestQueue'; + +jest.useFakeTimers(); + +const mockApiClient = jest.fn(() => Promise.resolve({ data: {} })); + +beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + // Ensure monitoring is stopped before each test + requestQueue.stopMonitoring(); +}); + +afterEach(() => { + requestQueue.stopMonitoring(); +}); + +describe('RequestQueue — background lifecycle', () => { + describe('startMonitoring()', () => { + it('creates a polling interval when called', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + requestQueue.startMonitoring(mockApiClient); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + setIntervalSpy.mockRestore(); + }); + + it('is idempotent — calling twice does not create two intervals', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + requestQueue.startMonitoring(mockApiClient); + requestQueue.startMonitoring(mockApiClient); // second call should no-op + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + setIntervalSpy.mockRestore(); + }); + }); + + describe('stopMonitoring()', () => { + it('clears the polling interval', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + requestQueue.startMonitoring(mockApiClient); + requestQueue.stopMonitoring(); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + clearIntervalSpy.mockRestore(); + }); + + it('is idempotent — calling when not running does not throw', () => { + expect(() => requestQueue.stopMonitoring()).not.toThrow(); + }); + + it('does not call clearInterval a second time if already stopped', () => { + requestQueue.startMonitoring(mockApiClient); + requestQueue.stopMonitoring(); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + requestQueue.stopMonitoring(); // second call — already null + expect(clearIntervalSpy).not.toHaveBeenCalled(); + clearIntervalSpy.mockRestore(); + }); + }); + + describe('resumeMonitoring()', () => { + it('restarts the interval after it has been stopped', () => { + requestQueue.startMonitoring(mockApiClient); + requestQueue.stopMonitoring(); + + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + requestQueue.resumeMonitoring(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + setIntervalSpy.mockRestore(); + }); + + it('is a no-op if startMonitoring was never called', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + requestQueue.resumeMonitoring(); // apiClientRef is null — should not throw + expect(setIntervalSpy).not.toHaveBeenCalled(); + setIntervalSpy.mockRestore(); + }); + }); + + describe('background → foreground cycle', () => { + it('can cycle stop → resume multiple times without creating extra intervals', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + requestQueue.startMonitoring(mockApiClient); // 1st interval + requestQueue.stopMonitoring(); + requestQueue.resumeMonitoring(); // 2nd interval + requestQueue.stopMonitoring(); + requestQueue.resumeMonitoring(); // 3rd interval + + // Each resume creates exactly one new interval (previous was cleared) + expect(setIntervalSpy).toHaveBeenCalledTimes(3); + setIntervalSpy.mockRestore(); + }); + }); +}); diff --git a/tests/services/syncService.test.ts b/tests/services/syncService.test.ts new file mode 100644 index 0000000..e2f6812 --- /dev/null +++ b/tests/services/syncService.test.ts @@ -0,0 +1,78 @@ +import syncService from '../../src/services/syncService'; + +jest.useFakeTimers(); + +beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + // Always start from a clean stopped state + syncService.stopAutoSync(); +}); + +afterEach(() => { + syncService.stopAutoSync(); +}); + +describe('SyncService — background lifecycle', () => { + describe('startAutoSync()', () => { + it('creates a recurring interval', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + syncService.startAutoSync(); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + setIntervalSpy.mockRestore(); + }); + + it('is idempotent — a second call before stopping does not add another interval', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + syncService.startAutoSync(); + syncService.startAutoSync(); // should no-op due to guard + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + setIntervalSpy.mockRestore(); + }); + }); + + describe('stopAutoSync()', () => { + it('clears the interval', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + syncService.startAutoSync(); + syncService.stopAutoSync(); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + clearIntervalSpy.mockRestore(); + }); + + it('is safe to call when not running', () => { + expect(() => syncService.stopAutoSync()).not.toThrow(); + }); + + it('does not call clearInterval when already stopped', () => { + syncService.startAutoSync(); + syncService.stopAutoSync(); + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + syncService.stopAutoSync(); // already stopped + expect(clearIntervalSpy).not.toHaveBeenCalled(); + clearIntervalSpy.mockRestore(); + }); + }); + + describe('background → foreground cycle', () => { + it('can restart after being stopped', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + syncService.startAutoSync(); + syncService.stopAutoSync(); + syncService.startAutoSync(); // restart after background + + expect(setIntervalSpy).toHaveBeenCalledTimes(2); + setIntervalSpy.mockRestore(); + }); + + it('schedules sync callback on the configured interval', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + syncService.startAutoSync(); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 30000); + setIntervalSpy.mockRestore(); + }); + }); +});