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
10 changes: 8 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ import socketService from './src/services/socket';
import syncService from './src/services/syncService';
import { useAppStore } from './src/store';
import { useNotificationStore } from './src/store/notificationStore';
import { warmCriticalCaches } from './src/services/cacheWarming';
import webVitalsService from './src/services/webVitals';
import { handleCacheVersionUpdate } from './src/utils/cacheVersioning';
import { requireEnvVariables } from './src/utils/env';
import { appLogger, logger } from './src/utils/logger';
import { appLogger } from './src/utils/logger';
import { handleNotificationReceived } from './src/utils/notificationHandlers';
import { prefetchExternalResources } from './src/utils/resourceHints';

Expand All @@ -54,6 +54,9 @@ initializeLogging().catch(err => {
console.error('[App] Failed to initialize logging:', err);
});

// Start Core Web Vitals monitoring
webVitalsService.init();

if (__DEV__) {
appLogger.infoSync('Development mode: centralized logger active');
LogBox.ignoreLogs(['Non-serializable values were found in the navigation state']);
Expand Down Expand Up @@ -107,6 +110,9 @@ const App = () => {
startupProgressService.startStep('data');
await new Promise(resolve => setTimeout(resolve, 500));
startupProgressService.completeStep('data');

// 5. Warm critical caches (user profile + home feed) in parallel
await warmCriticalCaches();
} catch (e) {
console.warn('Error during app initialization:', e);
// Mark the last step as failed
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"socket.io-client": "^4.8.3",
"web-vitals": "4.2.4",
"zustand": "^5.0.10"
},
"devDependencies": {
Expand Down
63 changes: 63 additions & 0 deletions src/__tests__/services/cacheWarming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { warmCriticalCaches } from '../../services/cacheWarming';
import { courseApi } from '../../services/api/courseApi';
import { userApi } from '../../services/api/userApi';
import { useAppStore } from '../../store';

jest.mock('../../services/api/courseApi', () => ({
courseApi: { getCourses: jest.fn() },
}));
jest.mock('../../services/api/userApi', () => ({
userApi: { getUser: jest.fn() },
}));
jest.mock('../../store', () => ({
useAppStore: { getState: jest.fn() },
}));

const getCourses = courseApi.getCourses as jest.Mock;
const getUser = userApi.getUser as jest.Mock;
const getState = useAppStore.getState as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
getCourses.mockResolvedValue([]);
getUser.mockResolvedValue({ id: 'u1' });
});

describe('warmCriticalCaches', () => {
it('always fetches the course list', async () => {
getState.mockReturnValue({ user: null });
await warmCriticalCaches();
expect(getCourses).toHaveBeenCalledTimes(1);
});

it('fetches user profile when userId is available', async () => {
getState.mockReturnValue({ user: { id: 'u1' } });
await warmCriticalCaches();
expect(getUser).toHaveBeenCalledWith('u1');
});

it('skips user profile fetch when not authenticated', async () => {
getState.mockReturnValue({ user: null });
await warmCriticalCaches();
expect(getUser).not.toHaveBeenCalled();
});

it('resolves even if course fetch fails', async () => {
getState.mockReturnValue({ user: null });
getCourses.mockRejectedValue(new Error('network'));
await expect(warmCriticalCaches()).resolves.toBeUndefined();
});

it('resolves even if user fetch fails', async () => {
getState.mockReturnValue({ user: { id: 'u1' } });
getUser.mockRejectedValue(new Error('network'));
await expect(warmCriticalCaches()).resolves.toBeUndefined();
});

it('fetches courses and user profile in parallel', async () => {
getState.mockReturnValue({ user: { id: 'u1' } });
await warmCriticalCaches();
expect(getCourses).toHaveBeenCalledTimes(1);
expect(getUser).toHaveBeenCalledTimes(1);
});
});
135 changes: 135 additions & 0 deletions src/__tests__/services/webVitals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { mobileAnalyticsService } from '../../services/mobileAnalytics';
import {
clearBaselines,
handleMetric,
setBaseline,
WEB_VITALS_THRESHOLDS,
} from '../../services/webVitals';
import { AnalyticsEvent, PerformanceMetric } from '../../utils/trackingEvents';

jest.mock('../../services/mobileAnalytics', () => ({
mobileAnalyticsService: { trackEvent: jest.fn() },
}));

const trackEvent = mobileAnalyticsService.trackEvent as jest.Mock;

function makeMetric(value: number, delta = value) {
return { value, delta, id: 'test-id', name: 'LCP', navigationType: 'navigate' } as any;
}

beforeEach(() => {
trackEvent.mockClear();
clearBaselines();
});

describe('WEB_VITALS_THRESHOLDS', () => {
it('defines thresholds for all 5 vitals', () => {
const keys = [
PerformanceMetric.LCP,
PerformanceMetric.FID,
PerformanceMetric.CLS,
PerformanceMetric.FCP,
PerformanceMetric.TTFB,
];
keys.forEach(k => {
expect(WEB_VITALS_THRESHOLDS[k]).toHaveProperty('good');
expect(WEB_VITALS_THRESHOLDS[k]).toHaveProperty('needsImprovement');
});
});
});

describe('handleMetric — rating', () => {
it('reports "good" for LCP <= 2500ms', () => {
handleMetric(PerformanceMetric.LCP, makeMetric(2000));
expect(trackEvent).toHaveBeenCalledWith(
AnalyticsEvent.WEB_VITALS_LCP,
expect.objectContaining({ metric_rating: 'good' })
);
});

it('reports "needs-improvement" for LCP between 2500 and 4000ms', () => {
handleMetric(PerformanceMetric.LCP, makeMetric(3000));
expect(trackEvent).toHaveBeenCalledWith(
AnalyticsEvent.WEB_VITALS_LCP,
expect.objectContaining({ metric_rating: 'needs-improvement' })
);
});

it('reports "poor" for LCP > 4000ms', () => {
handleMetric(PerformanceMetric.LCP, makeMetric(5000));
expect(trackEvent).toHaveBeenCalledWith(
AnalyticsEvent.WEB_VITALS_LCP,
expect.objectContaining({ metric_rating: 'poor' })
);
});

it('reports "good" for CLS <= 0.1', () => {
handleMetric(PerformanceMetric.CLS, makeMetric(0.05));
expect(trackEvent).toHaveBeenCalledWith(
AnalyticsEvent.WEB_VITALS_CLS,
expect.objectContaining({ metric_rating: 'good' })
);
});
});

describe('handleMetric — analytics event mapping', () => {
const cases: [PerformanceMetric, AnalyticsEvent][] = [
[PerformanceMetric.LCP, AnalyticsEvent.WEB_VITALS_LCP],
[PerformanceMetric.FID, AnalyticsEvent.WEB_VITALS_FID],
[PerformanceMetric.CLS, AnalyticsEvent.WEB_VITALS_CLS],
[PerformanceMetric.FCP, AnalyticsEvent.WEB_VITALS_FCP],
[PerformanceMetric.TTFB, AnalyticsEvent.WEB_VITALS_TTFB],
];

test.each(cases)('%s maps to %s', (metric, event) => {
handleMetric(metric, makeMetric(100));
expect(trackEvent).toHaveBeenCalledWith(event, expect.any(Object));
});
});

describe('handleMetric — regression detection', () => {
it('sets baseline on first reading and does not fire regression event', () => {
handleMetric(PerformanceMetric.LCP, makeMetric(2000));
const calls = trackEvent.mock.calls.map(c => c[0]);
expect(calls).not.toContain(AnalyticsEvent.WEB_VITALS_REGRESSION);
});

it('fires regression event when value is >20% above baseline', () => {
setBaseline(PerformanceMetric.LCP, 2000);
handleMetric(PerformanceMetric.LCP, makeMetric(2500)); // 25% worse
expect(trackEvent).toHaveBeenCalledWith(
AnalyticsEvent.WEB_VITALS_REGRESSION,
expect.objectContaining({
metric_name: PerformanceMetric.LCP,
baseline_value: 2000,
})
);
});

it('does not fire regression event when value is within 20% of baseline', () => {
setBaseline(PerformanceMetric.LCP, 2000);
handleMetric(PerformanceMetric.LCP, makeMetric(2300)); // 15% worse — OK
const calls = trackEvent.mock.calls.map(c => c[0]);
expect(calls).not.toContain(AnalyticsEvent.WEB_VITALS_REGRESSION);
});

it('includes regression_pct in the regression event payload', () => {
setBaseline(PerformanceMetric.TTFB, 800);
handleMetric(PerformanceMetric.TTFB, makeMetric(1000)); // 25% worse
expect(trackEvent).toHaveBeenCalledWith(
AnalyticsEvent.WEB_VITALS_REGRESSION,
expect.objectContaining({ regression_pct: 25 })
);
});
});

describe('setBaseline / clearBaselines', () => {
it('clearBaselines resets so next reading becomes new baseline', () => {
setBaseline(PerformanceMetric.FCP, 1000);
clearBaselines();
// First reading after clear should not trigger regression
handleMetric(PerformanceMetric.FCP, makeMetric(5000));
const calls = trackEvent.mock.calls.map(c => c[0]);
expect(calls).not.toContain(AnalyticsEvent.WEB_VITALS_REGRESSION);
});
});
2 changes: 1 addition & 1 deletion src/components/mobile/MobileProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ export const MobileProfile: React.FC<MobileProfileProps> = ({
</View>

{/* Quick stats strip */}
<View style={styles.statsStrip, { backgroundColor: cardBg, borderColor }]}>
<View style={[styles.statsStrip, { backgroundColor: cardBg, borderColor }]}>
{stripItems.map((s, i) => (
<View
key={`stat-${i}-${s.label}`}
Expand Down
31 changes: 31 additions & 0 deletions src/services/cacheWarming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useAppStore } from '../store';
import { appLogger } from '../utils/logger';
import { courseApi } from './api/courseApi';
import { userApi } from './api/userApi';

/**
* Warm critical caches in parallel during the splash screen so home screen
* data is ready before the user sees it.
*
* - User profile (requires authenticated userId)
* - Home feed / course list (always fetched)
*
* Failures are swallowed — warming is best-effort and must never block startup.
*/
export async function warmCriticalCaches(): Promise<void> {
const start = Date.now();

const userId = useAppStore.getState().user?.id;

const tasks: Promise<unknown>[] = [
courseApi.getCourses().catch(() => null),
];

if (userId) {
tasks.push(userApi.getUser(userId).catch(() => null));
}

await Promise.all(tasks);

appLogger.infoSync(`[CacheWarming] Completed in ${Date.now() - start}ms`);
}
Loading
Loading