Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 13 additions & 22 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,18 @@
import { AuthProvider, useAdaptiveTheme, useReviewMetrics } from './src/hooks';
import AppNavigator from './src/navigation/AppNavigator';
import {
apiClient,
getCacheStatus,
getRevalidatingCacheKeys,
subscribeToCacheStatus,
subscribeToCacheStatus

Check failure on line 31 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Module '"./src/services/api"' has no exported member 'subscribeToCacheStatus'. Did you mean to use 'import subscribeToCacheStatus from "./src/services/api"' instead?
} 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 {
Expand All @@ -59,7 +58,7 @@
subscribeToHydrationResetToast,
} from './src/store/persistence';
import { handleCacheVersionUpdate } from './src/utils/cacheVersioning';
import { requireEnvVariables } from './src/utils/env';

Check failure on line 61 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find module './src/utils/env' or its corresponding type declarations.
import { appLogger } from './src/utils/logger';

// Keep the splash screen visible while we fetch resources
Expand All @@ -84,9 +83,6 @@
} else {
// Strip all logs except errors in production for performance
console.log = () => {};
console.info = () => {};
console.warn = () => {};
console.debug = () => {};
}

const CacheRevalidationBanner = () => {
Expand Down Expand Up @@ -213,14 +209,17 @@
// 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),

Check failure on line 217 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Argument of type 'readonly [any, any, any]' is not assignable to parameter of type 'string | number | string[] | number[]'.
]);
} 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;
Expand All @@ -239,15 +238,7 @@
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 () => {
Expand Down Expand Up @@ -357,8 +348,8 @@
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
// #807: isFeatureType narrows string key to FeatureType
if ((Object.values(FeatureType) as string[]).includes(feature)) {

Check failure on line 351 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureType'.
degradationStore.setFeatureStatus(feature as FeatureType, info.status);

Check failure on line 352 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureType'.
}
}
});
Expand Down Expand Up @@ -403,8 +394,8 @@
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
// #807: isFeatureType narrows string key to FeatureType
if ((Object.values(FeatureType) as string[]).includes(feature)) {

Check failure on line 397 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureType'.
degradationStore.setFeatureStatus(feature as FeatureType, info.status);

Check failure on line 398 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureType'.
}
}
});
Expand Down Expand Up @@ -466,7 +457,7 @@
// Issue #820: read store directly rather than closed-over component state.
const store = useNotificationStore.getState();
store.addNotification({
id: notification.request.identifier,

Check failure on line 460 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Object literal may only specify known properties, and 'id' does not exist in type 'Omit<StoredNotification, "id" | "read" | "receivedAt">'.
type: (notification.request.content.data?.type as any) ?? 'general',
title: notification.request.content.title ?? '',
body: notification.request.content.body ?? '',
Expand Down Expand Up @@ -597,9 +588,9 @@
<AuthProvider>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<CacheRevalidationBanner />
<ScreenErrorBoundary screenName="AppNavigator">

Check failure on line 591 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'ScreenErrorBoundary'.
<AppNavigator />
</ScreenErrorBoundary>

Check failure on line 593 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'ScreenErrorBoundary'.
<NotificationPermissionExplanationSheet />
{showPreferencesResetToast ? <PreferencesResetToast /> : null}
<UpdatePromptModal
Expand Down
4 changes: 2 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }],
},
},
]);
]);
36 changes: 23 additions & 13 deletions src/services/api/axios.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -665,4 +675,4 @@ apiClient.interceptors.response.use(
}
);

export default apiClient;
export default apiClient;
6 changes: 4 additions & 2 deletions src/services/auth.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -85,10 +85,12 @@ export async function logout(): Promise<void> {
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);
Expand Down Expand Up @@ -133,4 +135,4 @@ export async function checkAuthStatus(): Promise<boolean> {
} finally {
store.setAuthLoading(false);
}
}
}
8 changes: 3 additions & 5 deletions src/services/fontService.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -369,4 +367,4 @@ export const fontOptimization = {
return { size: 0, type: 'unknown' };
}
},
};
};
26 changes: 13 additions & 13 deletions src/services/mobileAuth.ts
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -429,24 +429,24 @@ class MobileAuthService {

async logout(): Promise<void> {
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();
Expand Down Expand Up @@ -501,4 +501,4 @@ class MobileAuthService {
}

export const mobileAuthService = new MobileAuthService();
export default mobileAuthService;
export default mobileAuthService;
Loading