Skip to content
Open
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
17 changes: 12 additions & 5 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,7 +74,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)));
}
});

Expand Down Expand Up @@ -208,10 +210,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 fontService.loadFonts(CRITICAL_FONTS);
console.log(`[App] Critical fonts loaded in ${Date.now() - fontStart}ms`);
await Promise.all([
fontService.loadFonts(CRITICAL_FONTS),
Asset.loadAsync(CRITICAL_ASSETS),
]);
appLogger.infoSync(`[App] Critical 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 @@ -236,7 +243,7 @@ const App = () => {
InteractionManager.runAfterInteractions(async () => {
const start = Date.now();
await fontService.loadFonts(SECONDARY_FONTS);
console.log(`[App] Secondary fonts loaded in ${Date.now() - start}ms`);
appLogger.infoSync(`[App] Secondary fonts loaded in ${Date.now() - start}ms`);
});
}, [appIsReady]);

Expand Down
20 changes: 18 additions & 2 deletions src/components/mobile/MobileFormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ interface MobileFormInputProps {
onBlur?: TextInputProps['onBlur'];
}

export const MobileFormInput: React.FC<MobileFormInputProps> = ({
/**
* 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<MobileFormInputProps> = ({
label,
value,
onChangeText,
Expand Down Expand Up @@ -232,4 +240,12 @@ export const MobileFormInput: React.FC<MobileFormInputProps> = ({
)}
</View>
);
};
};

/**
* 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;
23 changes: 23 additions & 0 deletions src/constants/assets.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion src/hooks/useCourseProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function useCourseProgress({
const [fullProgress, setFullProgress] = useState<CourseProgress | null>(null);
const [isLoading, setIsLoading] = useState(true);

const { setCourseProgress } = useCourseProgressStore();
const setCourseProgress = useCourseProgressStore(state => state.setCourseProgress);

// Debounce timer ref for server sync
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down
6 changes: 4 additions & 2 deletions src/hooks/useCustomFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { loadAsync } from 'expo-font';
import { useEffect, useState } from 'react';
import { Platform } from 'react-native';

import { appLogger } from '../utils/logger';

// Font configuration
export interface FontConfig {
name: string;
Expand Down Expand Up @@ -91,7 +93,7 @@ async function loadSingleFont(config: FontConfig): Promise<boolean> {
return true;
})
.catch((error) => {
console.error(`Failed to load font ${config.name}:`, error);
appLogger.errorSync(`Failed to load font ${config.name}:`, error instanceof Error ? error : new Error(String(error)));
return false;
});

Expand Down Expand Up @@ -234,7 +236,7 @@ export async function preloadCriticalFonts() {
const { loaded, failed } = await loadFontsWithProgress(criticalFonts);

if (failed.length > 0) {
console.warn('Some critical fonts failed to load:', failed);
appLogger.warnSync('Some critical fonts failed to load:', { failed });
}

return { loaded, failed };
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/useNetworkStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

export type { ConnectionType, NetworkStatus };

Expand Down Expand Up @@ -61,7 +62,7 @@ export function useNetworkStatus() {
isFast = false;
}
} catch (error) {
console.warn('Failed to get cellular state', error);
appLogger.warnSync('Failed to get cellular state', { error: String(error) });
quality = 'unknown';
isFast = false;
}
Expand All @@ -72,7 +73,7 @@ export function useNetworkStatus() {

setConnectionQuality({ quality, isFast });
} catch (error) {
console.warn('Failed to get network state', error);
appLogger.warnSync('Failed to get network state', { error: String(error) });
setNetworkStatus({
isConnected: false,
isInternetReachable: false,
Expand Down
35 changes: 35 additions & 0 deletions src/services/api/courseApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
}
);
},
};
14 changes: 8 additions & 6 deletions src/services/fontService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Asset } from 'expo-asset';
import * as Font from 'expo-font';
import { Platform } from 'react-native';

import { appLogger } from '../utils/logger';

// Font metadata interface
export interface FontMetadata {
name: string;
Expand Down Expand Up @@ -53,7 +55,7 @@ class FontService {
});
}
} catch (error) {
console.error('Failed to load font metadata:', error);
appLogger.errorSync('Failed to load font metadata:', error instanceof Error ? error : new Error(String(error)));
}
}

Expand All @@ -63,7 +65,7 @@ class FontService {
const data = Object.fromEntries(this.metadata);
await AsyncStorage.setItem(this.cacheKey, JSON.stringify(data));
} catch (error) {
console.error('Failed to save font metadata:', error);
appLogger.errorSync('Failed to save font metadata:', error instanceof Error ? error : new Error(String(error)));
}
}

Expand All @@ -75,7 +77,7 @@ class FontService {
this.defaultSettings = JSON.parse(cached);
}
} catch (error) {
console.error('Failed to load font settings:', error);
appLogger.errorSync('Failed to load font settings:', error instanceof Error ? error : new Error(String(error)));
}
}

Expand All @@ -84,7 +86,7 @@ class FontService {
try {
await AsyncStorage.setItem(this.settingsKey, JSON.stringify(this.defaultSettings));
} catch (error) {
console.error('Failed to save font settings:', error);
appLogger.errorSync('Failed to save font settings:', error instanceof Error ? error : new Error(String(error)));
}
}

Expand Down Expand Up @@ -162,7 +164,7 @@ class FontService {

return true;
} catch (error) {
console.error(`Failed to load font ${name}:`, error);
appLogger.errorSync(`Failed to load font ${name}:`, error instanceof Error ? error : new Error(String(error)));
return false;
}
}
Expand Down Expand Up @@ -267,7 +269,7 @@ class FontService {
this.loadedFonts.add(name);
});
const elapsed = Date.now() - start;
console.log(`[FontService] Loaded ${fonts.length} font(s) in ${elapsed}ms`);
appLogger.infoSync(`[FontService] Loaded ${fonts.length} font(s) in ${elapsed}ms`);
}

// Preload critical fonts
Expand Down
78 changes: 78 additions & 0 deletions src/store/courseProgressStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,10 +27,39 @@ interface CourseProgressState {
lessonData?: Partial<LessonProgress>
) => 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<string, Lesson[]>; // 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<string, number>;
/**
* 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<void>;
/**
* 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<string, Lesson[]>,
lessonWindowPage: {} as Record<string, number>,
};

/**
Expand Down Expand Up @@ -109,6 +148,43 @@ export const useCourseProgressStore = create<CourseProgressState>()(
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.
}
},
};
},
{
Expand All @@ -121,6 +197,8 @@ export const useCourseProgressStore = create<CourseProgressState>()(
),
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.
}),
}
)
Expand Down
4 changes: 3 additions & 1 deletion src/store/createStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

import { appLogger } from '../utils/logger';

const storage = new MMKV();

/**
Expand Down Expand Up @@ -45,7 +47,7 @@ export const createStore = <T extends object>(
onRehydrateStorage: (state) => {
return (state, error) => {
if (error) {
console.log('an error happened during hydration', error)
appLogger.errorSync('an error happened during hydration', error instanceof Error ? error : new Error(String(error)))
}
}
},
Expand Down