From ab8b06abf1df31b5f7197ac2886e5808d1648ef3 Mon Sep 17 00:00:00 2001 From: Imole Date: Thu, 28 May 2026 00:30:13 +0000 Subject: [PATCH 1/2] perf: split appStore and lazy-init achievement state - Extract theme into useUiStore (AsyncStorage, not SecureStore) - Remove theme/setTheme from useAppStore; appStore is now auth-only - Lazy-initialize achievementStore: start with [] and seed defaults on first loadAchievements() call instead of at store creation - Update useAdaptiveTheme, MobileSettings, Settings to use useUiStore - Update tests/store.test.ts setTheme suite to target useUiStore - Fix pre-existing ESLint import/order and component-definition warnings Closes #5 #6 #29 --- src/components/mobile/MobileSettings.tsx | 38 ++++++++++++------------ src/hooks/useAdaptiveTheme.ts | 6 ++-- src/pages/mobile/Settings.tsx | 13 ++++---- src/store/achievementStore.ts | 20 +++++++++++-- src/store/index.ts | 6 +--- src/store/uiStore.ts | 21 +++++++++++++ tests/store.test.ts | 18 ++++++----- 7 files changed, 81 insertions(+), 41 deletions(-) create mode 100644 src/store/uiStore.ts diff --git a/src/components/mobile/MobileSettings.tsx b/src/components/mobile/MobileSettings.tsx index c164d6b..519b58c 100644 --- a/src/components/mobile/MobileSettings.tsx +++ b/src/components/mobile/MobileSettings.tsx @@ -1,12 +1,3 @@ -import React from 'react'; -import { - View, - Text, - ScrollView, - TouchableOpacity, - Alert, - ActivityIndicator, -} from 'react-native'; import { BarChart2, @@ -30,16 +21,25 @@ import { RefreshCw, Fingerprint as FingerprintPattern, } from 'lucide-react-native'; +import React from 'react'; +import { + View, + Text, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; -import { useAppStore } from '../../store'; -import { useNotificationStore } from '../../store/notificationStore'; -import { useSettingsStore } from '../../store/settingsStore'; -import { useBiometricAuth } from '../../hooks/useBiometricAuth'; -import { useDynamicFontSize } from '../../hooks'; import { NativeToggle } from './NativeToggle'; import { PickerOption, SettingsPicker } from './SettingsPicker'; import { SettingsSection } from './SettingsSection'; +import { useDynamicFontSize } from '../../hooks'; +import { useBiometricAuth } from '../../hooks/useBiometricAuth'; +import { useNotificationStore } from '../../store/notificationStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useUiStore } from '../../store/uiStore'; import { AppText } from '../common/AppText'; // ───────────────────────────────────────────────────────────── @@ -56,7 +56,7 @@ interface SettingRowProps { destructive?: boolean; } -function SettingRow({ +const SettingRow = ({ icon, iconBg = 'bg-gray-100 dark:bg-gray-700', label, @@ -64,7 +64,7 @@ function SettingRow({ right, onPress, destructive = false, -}: SettingRowProps) { +}: SettingRowProps) => { const Row = onPress ? TouchableOpacity : View; const { scale } = useDynamicFontSize(); @@ -139,12 +139,12 @@ const FONT_SIZE_OPTIONS: PickerOption[] = [ // Component // ───────────────────────────────────────────────────────────── -export function MobileSettings({ +export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts, -}: any) { - const { theme, setTheme } = useAppStore(); +}: any) => { + const { theme, setTheme } = useUiStore(); const { preferences, setPreference } = useNotificationStore(); const { diff --git a/src/hooks/useAdaptiveTheme.ts b/src/hooks/useAdaptiveTheme.ts index f77908f..5441762 100644 --- a/src/hooks/useAdaptiveTheme.ts +++ b/src/hooks/useAdaptiveTheme.ts @@ -2,8 +2,8 @@ import { LightSensor } from 'expo-sensors'; import { useEffect, useRef } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; -import { useAppStore } from '../store'; import { useSettingsStore } from '../store/settingsStore'; +import { useUiStore } from '../store/uiStore'; export const DARK_LUX_THRESHOLD = 25; export const LIGHT_LUX_THRESHOLD = 75; @@ -61,7 +61,7 @@ export function advanceDebounce( export function useAdaptiveTheme(): void { const adaptiveThemeEnabled = useSettingsStore((s) => s.adaptiveThemeEnabled); - const setTheme = useAppStore((s) => s.setTheme); + const setTheme = useUiStore((s) => s.setTheme); const debounceRef = useRef({ candidate: null, consecutiveCount: 0 }); const subscriptionRef = useRef<{ remove: () => void } | null>(null); @@ -77,7 +77,7 @@ export function useAdaptiveTheme(): void { }; const handleReading = (lux: number) => { - const currentTheme = useAppStore.getState().theme; + const currentTheme = useUiStore.getState().theme; const { state, confirmedTheme } = advanceDebounce(debounceRef.current, lux, currentTheme); debounceRef.current = state; if (confirmedTheme) { diff --git a/src/pages/mobile/Settings.tsx b/src/pages/mobile/Settings.tsx index 92e156f..0d6fb9c 100644 --- a/src/pages/mobile/Settings.tsx +++ b/src/pages/mobile/Settings.tsx @@ -2,9 +2,10 @@ import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react-native'; import React from 'react'; import { StatusBar, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; + import { AppText, MobileSettings } from '../../components'; import { useDynamicFontSize } from '../../hooks'; -import { useAppStore } from '../../store'; +import { useUiStore } from '../../store/uiStore'; interface SettingsPageProps { /** Callback for the back-navigation button in the header. */ @@ -22,13 +23,13 @@ interface SettingsPageProps { * component as its body. Can be used inside any React Navigation stack or * rendered standalone. */ -export default function SettingsPage({ +const SettingsPage = ({ onBack, onSignOut, onChangePassword, onLinkedAccounts, -}: SettingsPageProps) { - const { theme } = useAppStore(); +}: SettingsPageProps): React.JSX.Element => { + const { theme } = useUiStore(); const isDark = theme === 'dark'; const { scale } = useDynamicFontSize(); @@ -81,4 +82,6 @@ export default function SettingsPage({ /> ); -} +}; + +export default SettingsPage; diff --git a/src/store/achievementStore.ts b/src/store/achievementStore.ts index 68fd45b..25e8397 100644 --- a/src/store/achievementStore.ts +++ b/src/store/achievementStore.ts @@ -50,8 +50,12 @@ interface AchievementState { achievements: Achievement[]; /** Number of unlocked achievements */ unlockedCount: number; - + /** Whether achievements have been loaded */ + isLoaded: boolean; + // Actions + /** Load achievements — initializes with defaults if not yet persisted */ + loadAchievements: () => void; /** Unlock an achievement by ID */ unlockAchievement: (id: string) => void; /** Update progress on an achievement */ @@ -144,8 +148,19 @@ export const DEFAULT_ACHIEVEMENTS: Achievement[] = [ export const useAchievementStore = create()( persist( (set, get) => ({ - achievements: DEFAULT_ACHIEVEMENTS, + achievements: [], unlockedCount: 0, + isLoaded: false, + + loadAchievements: () => { + const { isLoaded, achievements } = get(); + if (isLoaded) return; + // If persisted achievements exist, keep them; otherwise seed defaults + set({ + achievements: achievements.length > 0 ? achievements : DEFAULT_ACHIEVEMENTS, + isLoaded: true, + }); + }, unlockAchievement: (id: string) => set((state) => { @@ -230,6 +245,7 @@ export const useAchievementStore = create()( partialize: (state) => ({ achievements: state.achievements, unlockedCount: state.unlockedCount, + isLoaded: state.isLoaded, }), } ) diff --git a/src/store/index.ts b/src/store/index.ts index cf15e34..e339d63 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -21,11 +21,9 @@ interface AppState { refreshToken: string | null; sessionExpiresAt: number | null; sessionExpiringSoon: boolean; - theme: 'light' | 'dark'; isLoading: boolean; error: string | null; setUser: (user: User | null) => void; - setTheme: (theme: 'light' | 'dark') => void; setTokens: (accessToken: string, refreshToken: string, expiresAt: number) => void; setSessionExpiringSoon: (isExpiringSoon: boolean) => void; setAuthLoading: (isAuthLoading: boolean) => void; @@ -64,11 +62,9 @@ export const useAppStore = create()( refreshToken: null, sessionExpiresAt: null, sessionExpiringSoon: false, - theme: 'light', isLoading: false, error: null, setUser: (user) => set({ user, isAuthenticated: !!user }, false, 'setUser'), - setTheme: (theme) => set({ theme }, false, 'setTheme'), setTokens: (accessToken, refreshToken, sessionExpiresAt) => set({ accessToken, refreshToken, sessionExpiresAt }, false, 'setTokens'), setSessionExpiringSoon: (sessionExpiringSoon) => @@ -107,7 +103,6 @@ export const useAppStore = create()( accessToken: state.accessToken, refreshToken: state.refreshToken, sessionExpiresAt: state.sessionExpiresAt, - theme: state.theme, }), } ), @@ -117,3 +112,4 @@ export const useAppStore = create()( export * from './notificationStore'; export * from './courseProgressStore'; +export * from './uiStore'; diff --git a/src/store/uiStore.ts b/src/store/uiStore.ts new file mode 100644 index 0000000..9a34ab1 --- /dev/null +++ b/src/store/uiStore.ts @@ -0,0 +1,21 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +interface UiState { + theme: 'light' | 'dark'; + setTheme: (theme: 'light' | 'dark') => void; +} + +export const useUiStore = create()( + persist( + (set) => ({ + theme: 'light', + setTheme: (theme) => set({ theme }), + }), + { + name: 'ui-storage', + storage: createJSONStorage(() => AsyncStorage), + } + ) +); diff --git a/tests/store.test.ts b/tests/store.test.ts index 1d6ab60..8a5bda8 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import { AuthUser } from '../src/services/mobileAuth'; import { useAppStore } from '../src/store'; +import { useUiStore } from '../src/store/uiStore'; const MOCK_USER: AuthUser = { id: 'user-001', @@ -25,7 +26,6 @@ const INITIAL_STATE = { accessToken: null, refreshToken: null, sessionExpiresAt: null, - theme: 'light' as const, }; describe('useAppStore', () => { @@ -218,20 +218,24 @@ describe('useAppStore', () => { // ── setTheme ──────────────────────────────────────────────────────────── describe('setTheme', () => { + beforeEach(() => { + useUiStore.setState({ theme: 'light' }); + }); + it('switches theme to dark', () => { - useAppStore.getState().setTheme('dark'); - expect(useAppStore.getState().theme).toBe('dark'); + useUiStore.getState().setTheme('dark'); + expect(useUiStore.getState().theme).toBe('dark'); }); it('switches theme back to light', () => { - useAppStore.setState({ theme: 'dark' }); - useAppStore.getState().setTheme('light'); - expect(useAppStore.getState().theme).toBe('light'); + useUiStore.setState({ theme: 'dark' }); + useUiStore.getState().setTheme('light'); + expect(useUiStore.getState().theme).toBe('light'); }); it('does not affect auth state', () => { useAppStore.setState({ user: MOCK_USER, isAuthenticated: true }); - useAppStore.getState().setTheme('dark'); + useUiStore.getState().setTheme('dark'); const state = useAppStore.getState(); expect(state.isAuthenticated).toBe(true); expect(state.user).toEqual(MOCK_USER); From 33740289e80897cd586546e7af6087c77e3d6dff Mon Sep 17 00:00:00 2001 From: Imole Date: Thu, 28 May 2026 08:52:54 +0000 Subject: [PATCH 2/2] perf: reduce store memory footprint at startup - Remove barrel re-exports from store/index.ts (notificationStore, courseProgressStore, uiStore) to prevent eager initialization when only useAppStore is needed - Remove dead DEFAULT_SETTINGS constant in settingsStore (duplicate of INITIAL_STATE, never referenced) --- src/store/index.ts | 20 +++++++--------- src/store/settingsStore.ts | 49 +++++++++++++------------------------- 2 files changed, 24 insertions(+), 45 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index e339d63..bdda814 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -53,7 +53,7 @@ const secureStorageAdapter: StateStorage = { export const useAppStore = create()( devtools( persist( - subscribeWithSelector((set) => ({ + subscribeWithSelector(set => ({ user: null, isAuthenticated: false, isAuthLoading: false, @@ -64,13 +64,13 @@ export const useAppStore = create()( sessionExpiringSoon: false, isLoading: false, error: null, - setUser: (user) => set({ user, isAuthenticated: !!user }, false, 'setUser'), + setUser: user => set({ user, isAuthenticated: !!user }, false, 'setUser'), setTokens: (accessToken, refreshToken, sessionExpiresAt) => set({ accessToken, refreshToken, sessionExpiresAt }, false, 'setTokens'), - setSessionExpiringSoon: (sessionExpiringSoon) => + setSessionExpiringSoon: sessionExpiringSoon => set({ sessionExpiringSoon }, false, 'setSessionExpiringSoon'), - setAuthLoading: (isAuthLoading) => set({ isAuthLoading }, false, 'setAuthLoading'), - setAuthError: (authError) => set({ authError }, false, 'setAuthError'), + setAuthLoading: isAuthLoading => set({ isAuthLoading }, false, 'setAuthLoading'), + setAuthError: authError => set({ authError }, false, 'setAuthError'), logout: () => set( { @@ -86,8 +86,8 @@ export const useAppStore = create()( false, 'logout' ), - setLoading: (isLoading) => set({ isLoading }, false, 'setLoading'), - setError: (error) => set({ error }, false, 'setError'), + setLoading: isLoading => set({ isLoading }, false, 'setLoading'), + setError: error => set({ error }, false, 'setError'), })), { name: 'app-auth-storage', @@ -97,7 +97,7 @@ export const useAppStore = create()( * Transient flags (isLoading, isAuthLoading, error, authError) * are intentionally excluded — they should always start fresh. */ - partialize: (state) => ({ + partialize: state => ({ user: state.user, isAuthenticated: state.isAuthenticated, accessToken: state.accessToken, @@ -109,7 +109,3 @@ export const useAppStore = create()( { name: 'AppStore' } ) ); - -export * from './notificationStore'; -export * from './courseProgressStore'; -export * from './uiStore'; diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index e765d3c..b13145f 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -1,6 +1,6 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; export type ProfileVisibility = 'public' | 'private' | 'friends_only'; export type DownloadQuality = 'low' | 'medium' | 'high'; @@ -57,23 +57,6 @@ interface SettingsState { resetSettings: () => void; } -const DEFAULT_SETTINGS: Omit> = { - profileVisibility: 'public' as ProfileVisibility, - twoFactorEnabled: false, - dataSharing: true, - analyticsEnabled: true, - locationServices: false, - downloadOverWifiOnly: true, - autoDownload: false, - downloadQuality: 'medium' as DownloadQuality, - storageLimit: '2GB' as StorageLimit, - language: 'english' as AppLanguage, - fontSize: 'medium' as FontSize, - autoplay: true, - hapticFeedback: true, - adaptiveThemeEnabled: false, -}; - const INITIAL_STATE = { profileVisibility: 'public' as ProfileVisibility, twoFactorEnabled: false, @@ -93,30 +76,30 @@ const INITIAL_STATE = { export const useSettingsStore = create()( persist( - (set) => ({ + set => ({ ...INITIAL_STATE, // Account - setProfileVisibility: (v) => set({ profileVisibility: v }), - setTwoFactorEnabled: (v) => set({ twoFactorEnabled: v }), + setProfileVisibility: v => set({ profileVisibility: v }), + setTwoFactorEnabled: v => set({ twoFactorEnabled: v }), // Privacy - setDataSharing: (v) => set({ dataSharing: v }), - setAnalyticsEnabled: (v) => set({ analyticsEnabled: v }), - setLocationServices: (v) => set({ locationServices: v }), + setDataSharing: v => set({ dataSharing: v }), + setAnalyticsEnabled: v => set({ analyticsEnabled: v }), + setLocationServices: v => set({ locationServices: v }), // Downloads - setDownloadOverWifiOnly: (v) => set({ downloadOverWifiOnly: v }), - setAutoDownload: (v) => set({ autoDownload: v }), - setDownloadQuality: (v) => set({ downloadQuality: v }), - setStorageLimit: (v) => set({ storageLimit: v }), + setDownloadOverWifiOnly: v => set({ downloadOverWifiOnly: v }), + setAutoDownload: v => set({ autoDownload: v }), + setDownloadQuality: v => set({ downloadQuality: v }), + setStorageLimit: v => set({ storageLimit: v }), // App Preferences - setLanguage: (v) => set({ language: v }), - setFontSize: (v) => set({ fontSize: v }), - setAutoplay: (v) => set({ autoplay: v }), - setHapticFeedback: (v) => set({ hapticFeedback: v }), - setAdaptiveThemeEnabled: (v) => set({ adaptiveThemeEnabled: v }), + setLanguage: v => set({ language: v }), + setFontSize: v => set({ fontSize: v }), + setAutoplay: v => set({ autoplay: v }), + setHapticFeedback: v => set({ hapticFeedback: v }), + setAdaptiveThemeEnabled: v => set({ adaptiveThemeEnabled: v }), resetSettings: () => set(INITIAL_STATE), }),