diff --git a/App.tsx b/App.tsx index c4b65a73..fde78d88 100644 --- a/App.tsx +++ b/App.tsx @@ -13,7 +13,9 @@ import { View, } from 'react-native'; +import { Asset } from 'expo-asset'; import * as Updates from 'expo-updates'; +import { CRITICAL_ASSETS } from './src/constants/assets'; import StorybookUI from './.rnstorybook'; import './global.css'; @@ -73,7 +75,7 @@ requireEnvVariables(); // Initialize centralized logging on app start initializeLogging().catch(err => { if (__DEV__) { - console.error('[App] Failed to initialize logging:', err); + appLogger.errorSync('[App] Failed to initialize logging:', err instanceof Error ? err : new Error(String(err))); } }); @@ -209,8 +211,15 @@ const App = () => { useEffect(() => { async function prepareApp() { try { - // 1. Load critical fonts (used on first screen) synchronously before splash hides + // 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 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`); diff --git a/src/components/mobile/MobileFormInput.tsx b/src/components/mobile/MobileFormInput.tsx index 54ffe9ca..e4a99630 100644 --- a/src/components/mobile/MobileFormInput.tsx +++ b/src/components/mobile/MobileFormInput.tsx @@ -49,7 +49,15 @@ interface MobileFormInputProps { onBlur?: TextInputProps['onBlur']; } -export const MobileFormInput: React.FC = ({ +/** + * MobileFormInput — wrapped in React.memo to prevent re-renders on every + * keystroke in multi-field forms. + * + * ⚠️ Call-site requirement: pass `onChangeText` wrapped in `useCallback` so + * the memo comparison stays stable and sibling inputs are not re-rendered when + * only one field changes. + */ +const MobileFormInputBase: React.FC = ({ label, value, onChangeText, @@ -232,4 +240,12 @@ export const MobileFormInput: React.FC = ({ )} ); -}; \ No newline at end of file +}; + +/** + * Named export for backwards compatibility — points to the memoised component. + * Callers that already used `{ MobileFormInput }` continue to work unchanged. + */ +export const MobileFormInput = React.memo(MobileFormInputBase); + +export default MobileFormInput; diff --git a/src/constants/assets.ts b/src/constants/assets.ts new file mode 100644 index 00000000..c709b63a --- /dev/null +++ b/src/constants/assets.ts @@ -0,0 +1,23 @@ +/** + * CRITICAL_ASSETS — images that must be preloaded during the splash-screen + * phase (before the first meaningful paint) to avoid visible placeholder + * flicker on first-time screen visits. + * + * Rules for inclusion: + * - Shown on the very first screen the user sees after login + * - Used as avatar / logo placeholders that would flash if loaded lazily + * - Appear in navigation tabs visible on every screen + * + * Use `Asset.loadAsync(CRITICAL_ASSETS)` inside the startup `Promise.all` + * alongside font loading so that everything resolves before `SplashScreen.hideAsync()`. + */ +export const CRITICAL_ASSETS = [ + // App icon – used in splash, navigation header and share sheets + require('../../assets/images/icon.png'), + + // Splash / loading screen graphic + require('../../assets/images/splash-icon.png'), + + // Avatar placeholder shown before user profile data is fetched + require('../../assets/images/ios-icon.png'), +] as const; diff --git a/src/hooks/useCourseProgress.ts b/src/hooks/useCourseProgress.ts index 7a3b1a23..8850aec5 100644 --- a/src/hooks/useCourseProgress.ts +++ b/src/hooks/useCourseProgress.ts @@ -44,7 +44,7 @@ export function useCourseProgress({ const [fullProgress, setFullProgress] = useState(null); const [isLoading, setIsLoading] = useState(true); - const { setCourseProgress } = useCourseProgressStore(); + const setCourseProgress = useCourseProgressStore(state => state.setCourseProgress); // Debounce timer ref for server sync const syncTimerRef = useRef | null>(null); diff --git a/src/hooks/useCustomFonts.ts b/src/hooks/useCustomFonts.ts index 1549ae8b..e83a77e9 100644 --- a/src/hooks/useCustomFonts.ts +++ b/src/hooks/useCustomFonts.ts @@ -3,6 +3,7 @@ import { loadAsync } from 'expo-font'; import { useEffect, useState } from 'react'; import { Platform } from 'react-native'; +import { appLogger } from '../utils/logger'; import logger from '../utils/logger'; // Font configuration @@ -93,6 +94,7 @@ async function loadSingleFont(config: FontConfig): Promise { return true; }) .catch((error) => { + appLogger.errorSync(`Failed to load font ${config.name}:`, error instanceof Error ? error : new Error(String(error))); logger.errorSync(`Failed to load font ${config.name}:`, error); return false; }); @@ -236,6 +238,7 @@ export async function preloadCriticalFonts() { const { loaded, failed } = await loadFontsWithProgress(criticalFonts); if (failed.length > 0) { + appLogger.warnSync('Some critical fonts failed to load:', { failed }); logger.warnSync('Some critical fonts failed to load:', { failed }); } diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index 94190b1d..ee3448c8 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -2,6 +2,7 @@ import { Network } from 'expo-network'; import { useState, useEffect, useCallback } from 'react'; import { networkMonitor, type ConnectionType, type NetworkStatus } from '../services/networkMonitor'; +import { appLogger } from '../utils/logger'; import logger from '../utils/logger'; export type { ConnectionType, NetworkStatus }; @@ -62,6 +63,7 @@ export function useNetworkStatus() { isFast = false; } } catch (error) { + appLogger.warnSync('Failed to get cellular state', { error: String(error) }); logger.warnSync('Failed to get cellular state', { error }); quality = 'unknown'; isFast = false; @@ -73,6 +75,7 @@ export function useNetworkStatus() { setConnectionQuality({ quality, isFast }); } catch (error) { + appLogger.warnSync('Failed to get network state', { error: String(error) }); logger.warnSync('Failed to get network state', { error }); setNetworkStatus({ isConnected: false, diff --git a/src/services/api/courseApi.ts b/src/services/api/courseApi.ts index 257a4443..ada2071a 100644 --- a/src/services/api/courseApi.ts +++ b/src/services/api/courseApi.ts @@ -73,4 +73,39 @@ export const courseApi = { invalidateCourse(id: string): void { invalidateCacheByTags([COURSE_TAG, courseTag(id)]); }, + + /** + * loadLessonsPage — fetch a paginated slice of lesson metadata for a course. + * + * Used by the windowed loading implementation in courseProgressStore to avoid + * loading all 50+ lessons into memory at once. Only metadata (IDs, titles, + * durations, video URLs) is fetched; heavy content is loaded on demand. + * + * @param courseId - Course to paginate lessons for. + * @param page - 1-based page number. + * @param limit - Lessons per page (default 5 — current ± 2 window). + */ + async loadLessonsPage( + courseId: string, + page: number, + limit = 5 + ): Promise<{ lessons: import('../../types/course').Lesson[]; totalLessons: number; page: number; totalPages: number }> { + const cacheKey = `courses:${courseId}:lessons:page=${page}:limit=${limit}`; + return fetchWithSWR( + cacheKey, + () => + apiClient + .get<{ lessons: import('../../types/course').Lesson[]; totalLessons: number; page: number; totalPages: number }>( + `/courses/${courseId}/lessons`, + { params: { page, limit } } + ) + .then(r => r.data), + TTL, + STALE_TTL, + { + dataType: 'lesson-page', + tags: [COURSE_TAG, courseTag(courseId)], + } + ); + }, }; \ No newline at end of file diff --git a/src/services/fontService.ts b/src/services/fontService.ts index 1f871913..8ca5a953 100644 --- a/src/services/fontService.ts +++ b/src/services/fontService.ts @@ -3,6 +3,7 @@ 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'; // Font metadata interface @@ -55,6 +56,7 @@ class FontService { }); } } catch (error) { + appLogger.errorSync('Failed to load font metadata:', error instanceof Error ? error : new Error(String(error))); logger.errorSync('Failed to load font metadata:', error as Error); } } @@ -65,6 +67,7 @@ class FontService { const data = Object.fromEntries(this.metadata); await AsyncStorage.setItem(this.cacheKey, JSON.stringify(data)); } catch (error) { + appLogger.errorSync('Failed to save font metadata:', error instanceof Error ? error : new Error(String(error))); logger.errorSync('Failed to save font metadata:', error as Error); } } @@ -77,6 +80,7 @@ class FontService { this.defaultSettings = JSON.parse(cached); } } catch (error) { + appLogger.errorSync('Failed to load font settings:', error instanceof Error ? error : new Error(String(error))); logger.errorSync('Failed to load font settings:', error as Error); } } @@ -86,6 +90,7 @@ class FontService { try { await AsyncStorage.setItem(this.settingsKey, JSON.stringify(this.defaultSettings)); } catch (error) { + appLogger.errorSync('Failed to save font settings:', error instanceof Error ? error : new Error(String(error))); logger.errorSync('Failed to save font settings:', error as Error); } } @@ -164,6 +169,7 @@ class FontService { return true; } 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; } @@ -269,6 +275,7 @@ class FontService { this.loadedFonts.add(name); }); const elapsed = Date.now() - start; + appLogger.infoSync(`[FontService] Loaded ${fonts.length} font(s) in ${elapsed}ms`); logger.infoSync(`[FontService] Loaded ${fonts.length} font(s) in ${elapsed}ms`); } diff --git a/src/store/courseProgressStore.ts b/src/store/courseProgressStore.ts index a0e9a22f..7d8bf1a5 100644 --- a/src/store/courseProgressStore.ts +++ b/src/store/courseProgressStore.ts @@ -4,6 +4,16 @@ import { persist } from 'zustand/middleware'; import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; import type { CourseProgress, LessonProgress } from '../types/course'; +import type { Lesson } from '../types/course'; + +// ── Windowed lesson loading constants ───────────────────────────────────────── +/** + * Number of lessons to load ahead and behind the current lesson (i.e. the + * "window radius"). At radius 2 we keep at most 5 lessons in memory at a time, + * which limits the in-memory object graph to < 50 MB even on 50-lesson courses. + */ +const LESSON_WINDOW_RADIUS = 2; +const LESSON_PAGE_SIZE = LESSON_WINDOW_RADIUS * 2 + 1; // 5 lessons per fetch interface CourseProgressState { // keyed by courseId @@ -17,10 +27,39 @@ interface CourseProgressState { lessonData?: Partial ) => void; isCourseComplete: (courseId: string, totalLessons: number) => boolean; + + // ── Windowed lesson loading (issue #817) ────────────────────────────────── + /** + * In-memory window of lesson metadata for the currently active course. + * Only the current ± LESSON_WINDOW_RADIUS lessons are kept here, limiting + * memory usage on large courses with many lessons. + */ + lessonWindow: Record; // keyed by courseId + /** + * The 1-based page that was last loaded for each course. + * Stored so the viewer can detect when the window needs sliding. + */ + lessonWindowPage: Record; + /** + * Load (or slide) the lesson window to the page containing `currentLessonIndex`. + * Frees the previous window from memory before setting the new one. + * + * @param courseId - Course whose lessons should be windowed. + * @param currentLessonIndex - 0-based absolute index of the active lesson. + * @param totalLessons - Total lesson count for the course. + */ + loadLessonWindow: (courseId: string, currentLessonIndex: number, totalLessons: number) => Promise; + /** + * Return the currently loaded lesson slice for a course. + * Returns an empty array if no window has been loaded yet. + */ + getLessonWindow: (courseId: string) => Lesson[]; } const INITIAL_COURSE_PROGRESS_STATE = { progressMap: {}, + lessonWindow: {} as Record, + lessonWindowPage: {} as Record, }; /** @@ -109,6 +148,43 @@ export const useCourseProgressStore = create()( const completedLessons = Object.values(progress.lessons).filter(l => l.completed).length; return completedLessons === totalLessons || progress.overallProgress >= 99.5; }, + + // ── Windowed lesson loading (issue #817) ──────────────────────────── + lessonWindow: {}, + lessonWindowPage: {}, + + getLessonWindow: (courseId) => get().lessonWindow[courseId] ?? [], + + loadLessonWindow: async (courseId, currentLessonIndex, totalLessons) => { + // Compute which 1-based page contains the current lesson index + const targetPage = Math.floor(currentLessonIndex / LESSON_PAGE_SIZE) + 1; + const currentPage = get().lessonWindowPage[courseId]; + + // Skip if the required window is already loaded + if (currentPage === targetPage) return; + + try { + // Lazy-import the API to avoid a circular dependency at module load time + const { courseApi } = await import('../services/api/courseApi'); + const result = await courseApi.loadLessonsPage(courseId, targetPage, LESSON_PAGE_SIZE); + + set(s => ({ + // Free the previous window before storing the new one so the old + // lesson objects are eligible for GC (#817: memory management) + lessonWindow: { + ...s.lessonWindow, + [courseId]: result.lessons, + }, + lessonWindowPage: { + ...s.lessonWindowPage, + [courseId]: targetPage, + }, + })); + } catch (_err) { + // Non-fatal: the viewer falls back to the full lesson list supplied + // via the course prop when the window fails to load. + } + }, }; }, { @@ -121,6 +197,8 @@ export const useCourseProgressStore = create()( ), partialize: state => ({ progressMap: state.progressMap, + // lessonWindow and lessonWindowPage are transient — they are rebuilt on + // demand from the API and should not bloat AsyncStorage with lesson data. }), } ) diff --git a/src/store/createStore.ts b/src/store/createStore.ts index 2cfd0ea2..c12732c2 100644 --- a/src/store/createStore.ts +++ b/src/store/createStore.ts @@ -10,6 +10,8 @@ import { import { immer } from 'zustand/middleware/immer'; import logger from '../utils/logger'; +import { appLogger } from '../utils/logger'; + const storage = new MMKV(); /** @@ -46,6 +48,7 @@ export const createStore = ( onRehydrateStorage: (state) => { return (state, error) => { if (error) { + appLogger.errorSync('an error happened during hydration', error instanceof Error ? error : new Error(String(error))) logger.errorSync('an error happened during hydration', error as Error) } }