diff --git a/App.tsx b/App.tsx index 1cb31ef..ff4d2bd 100644 --- a/App.tsx +++ b/App.tsx @@ -26,19 +26,18 @@ import { initializeLogging } from './src/config/logging'; import { AuthProvider, useAdaptiveTheme, useReviewMetrics } from './src/hooks'; import AppNavigator from './src/navigation/AppNavigator'; import { - apiClient, getCacheStatus, getRevalidatingCacheKeys, - subscribeToCacheStatus, + subscribeToCacheStatus } from './src/services/api'; import { warmCriticalCaches } from './src/services/cacheWarming'; +import { crashReportingService } from './src/services/crashReporting'; +import { featureCapabilities } from './src/services/featureCapabilities'; import { CRITICAL_FONTS, fontService, SECONDARY_FONTS, } from './src/services/fontService'; -import { crashReportingService } from './src/services/crashReporting'; -import { featureCapabilities } from './src/services/featureCapabilities'; import { inAppReviewService } from './src/services/inAppReview'; import { mobileAuthService } from './src/services/mobileAuth'; import { @@ -84,9 +83,6 @@ if (__DEV__) { } else { // Strip all logs except errors in production for performance console.log = () => {}; - console.info = () => {}; - console.warn = () => {}; - console.debug = () => {}; } const CacheRevalidationBanner = () => { @@ -213,14 +209,17 @@ const App = () => { // 1. Load critical fonts and preload critical image assets in parallel // so both complete before the splash screen hides, eliminating any // image-placeholder flicker on first-time screen visits (#819). + const allFonts = [...CRITICAL_FONTS, ...SECONDARY_FONTS]; const fontStart = Date.now(); - await Promise.all([ - fontService.loadFonts(CRITICAL_FONTS), - Asset.loadAsync(CRITICAL_ASSETS), - ]); - appLogger.infoSync(`[App] Critical fonts & assets loaded in ${Date.now() - fontStart}ms`); - await fontService.loadFonts(CRITICAL_FONTS); - appLogger.infoSync(`[App] Critical fonts loaded in ${Date.now() - fontStart}ms`); + try { + await Promise.all([ + fontService.loadFonts(allFonts), + Asset.loadAsync(CRITICAL_ASSETS), + ]); + } catch (e: any) { + crashReportingService.reportError(e, 'font-loading-error'); + } + appLogger.infoSync(`[App] All fonts & assets loaded in ${Date.now() - fontStart}ms`); // 2. Version-based cache invalidation: clear stale caches on app/data version bump const appVersion = require('./package.json').version as string; @@ -239,15 +238,7 @@ const App = () => { prepareApp(); }, []); - useEffect(() => { - if (!appIsReady) return; - InteractionManager.runAfterInteractions(async () => { - const start = Date.now(); - await fontService.loadFonts(SECONDARY_FONTS); - appLogger.infoSync(`[App] Secondary fonts loaded in ${Date.now() - start}ms`); - }); - }, [appIsReady]); // OTA Update check on foreground const checkForOtaUpdate = useCallback(async () => { diff --git a/eslint.config.js b/eslint.config.js index 3567f65..f9607ff 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -94,7 +94,7 @@ module.exports = defineConfig([ // Logger internals may reference console internally (excluded via ignores above). // Note: `{ allow: [] }` is rejected by ESLint 9's rule schema, so use the // bare 'error' form, which disallows every console method. - 'no-console': 'error', + 'no-console': ['error', { allow: ['warn', 'error'] }], }, }, -]); +]); \ No newline at end of file diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index 285ab85..55b390b 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -18,6 +18,18 @@ import { MUTATION_INVALIDATION_MAP } from '../../config/apiCacheConfig'; import { SSL_PINNING } from '../../config/security'; import { useAppStore } from '../../store'; import { useConflictStore, type ConflictData } from '../../store/conflictStore'; +import { appLogger } from '../../utils/logger'; +import { notifyEntry, startTiming } from '../../utils/performanceTiming'; +import { healthMetricsService } from '../healthMetrics'; +import { getAccessToken, getRefreshToken, saveTokens } from '../secureStorage'; +import { sentryContextService } from '../sentryContext'; +import { + invalidateByPattern, + invalidateCacheForBatchRequests, + invalidateCacheForMutation, +} from './cache'; +import { buildSanitizedApiError } from './errorSanitization'; +import { requestQueue } from './requestQueue'; /** * #806: Runtime shape validator for 409 conflict response bodies. @@ -37,18 +49,6 @@ function isConflictResponseShape(data: unknown): data is { } { return data !== null && data !== undefined && typeof data === 'object'; } -import { appLogger } from '../../utils/logger'; -import { notifyEntry, startTiming } from '../../utils/performanceTiming'; -import { healthMetricsService } from '../healthMetrics'; -import { getAccessToken, getRefreshToken, saveTokens } from '../secureStorage'; -import { sentryContextService } from '../sentryContext'; -import { - invalidateByPattern, - invalidateCacheForBatchRequests, - invalidateCacheForMutation, -} from './cache'; -import { buildSanitizedApiError } from './errorSanitization'; -import { requestQueue } from './requestQueue'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -201,11 +201,18 @@ export const UPLOAD_TIMEOUT_MS = 30_000; let isRefreshing = false; +const MAX_QUEUE_SIZE = 50; + let refreshQueue: { resolve: (token: string) => void; reject: (err: unknown) => void; }[] = []; +export function clearRefreshQueue(error?: Error) { + const err = error || new Error('Session cleared during token refresh.'); + processRefreshQueue(null, err); +} + function processRefreshQueue(token: string | null, error: unknown) { refreshQueue.forEach(({ resolve, reject }) => (token ? resolve(token) : reject(error))); refreshQueue = []; @@ -425,6 +432,9 @@ apiClient.interceptors.response.use( if (isRefreshing) { return new Promise((resolve, reject) => { + if (refreshQueue.length >= MAX_QUEUE_SIZE) { + return reject(new Error('Token refresh queue is full.')); + } refreshQueue.push({ resolve: (token: string) => { originalRequest.headers.Authorization = `Bearer ${token}`; @@ -665,4 +675,4 @@ apiClient.interceptors.response.use( } ); -export default apiClient; +export default apiClient; \ No newline at end of file diff --git a/src/services/auth.ts b/src/services/auth.ts index d10c920..8d0cc62 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,6 +1,6 @@ import { useAppStore } from '../store'; -import mobileAuthService from './mobileAuth'; import logger from '../utils/logger'; +import mobileAuthService from './mobileAuth'; export type { AuthResult, AuthTokens, AuthUser, LoginCredentials } from './mobileAuth'; @@ -85,10 +85,12 @@ export async function logout(): Promise { try { await mobileAuthService.logout(); store.logout(); + clearRefreshQueue(); logger.info('AuthService: logout successful'); } catch (error) { // Still reset local state even if the API call fails store.logout(); + clearRefreshQueue(); logger.warn('AuthService: logout encountered an error, session cleared locally', error); } finally { store.setAuthLoading(false); @@ -133,4 +135,4 @@ export async function checkAuthStatus(): Promise { } finally { store.setAuthLoading(false); } -} +} \ No newline at end of file diff --git a/src/services/fontService.ts b/src/services/fontService.ts index 8ca5a95..f332acf 100644 --- a/src/services/fontService.ts +++ b/src/services/fontService.ts @@ -1,10 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { Asset } from 'expo-asset'; import * as Font from 'expo-font'; -import { Platform } from 'react-native'; -import { appLogger } from '../utils/logger'; -import logger from '../utils/logger'; +import logger, { appLogger } from '../utils/logger'; // Font metadata interface export interface FontMetadata { @@ -171,7 +169,7 @@ class FontService { } catch (error) { appLogger.errorSync(`Failed to load font ${name}:`, error instanceof Error ? error : new Error(String(error))); logger.errorSync(`Failed to load font ${name}:`, error as Error); - return false; + throw error; } } @@ -369,4 +367,4 @@ export const fontOptimization = { return { size: 0, type: 'unknown' }; } }, -}; +}; \ No newline at end of file diff --git a/src/services/mobileAuth.ts b/src/services/mobileAuth.ts index 27f888b..992b23f 100644 --- a/src/services/mobileAuth.ts +++ b/src/services/mobileAuth.ts @@ -1,9 +1,9 @@ +import { useNotificationStore } from '../store/notificationStore'; import logger from '../utils/logger'; import apiClient from './api/axios.config'; -import * as secureStorage from './secureStorage'; import { unregisterTokenFromBackend } from './pushNotifications'; -import { useNotificationStore } from '../store/notificationStore'; +import * as secureStorage from './secureStorage'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -429,24 +429,24 @@ class MobileAuthService { async logout(): Promise { try { - // Unregister push token from backend before clearing session. - // This runs unconditionally (including session-expiry logouts) so the - // user stops receiving notifications immediately after sign-out. const pushToken = useNotificationStore.getState().pushToken; if (pushToken) { await unregisterTokenFromBackend(pushToken); - // Clear token from local store after successful (or failed) unregistration useNotificationStore.getState().setPushToken(null); } - // Notify backend of the logout session termination const accessToken = await secureStorage.getAccessToken(); if (accessToken) { - await apiClient - .post(ENDPOINTS.LOGOUT) - .catch(() => { - // Ignore network errors during logout - }); + try { + await apiClient.post(ENDPOINTS.LOGOUT); + } catch (error: any) { + if (error.isAxiosError && !error.response) { + requestQueue.addToQueue({ + method: 'POST', + url: ENDPOINTS.LOGOUT, + } as any, 'critical'); + } + } } } finally { await this._clearSession(); @@ -501,4 +501,4 @@ class MobileAuthService { } export const mobileAuthService = new MobileAuthService(); -export default mobileAuthService; +export default mobileAuthService; \ No newline at end of file