diff --git a/App.tsx b/App.tsx index c27a068..2693fab 100644 --- a/App.tsx +++ b/App.tsx @@ -44,6 +44,10 @@ import socketService from './src/services/socket'; import { syncService } from './src/services/syncService'; // Fixed naming convention from the merge conflict import { useAppStore, useDeviceStore, useNotificationStore } from './src/store'; // Added missing store imports import { useDegradationStore } from './src/store/degradationStore'; +import { + consumeHydrationResetToast, + subscribeToHydrationResetToast, +} from './src/store/persistence'; import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; import { requireEnvVariables } from './src/utils/env'; import { appLogger } from './src/utils/logger'; @@ -119,6 +123,29 @@ const CacheRevalidationBanner = () => { ); }; +const PreferencesResetToast = () => ( + + Your preferences were reset + +); + let _compromisedAlertShown = false; function showCompromisedAlert(): void { @@ -140,6 +167,32 @@ const App = () => { const appStateRef = useRef(AppState.currentState); const [appIsReady, setAppIsReady] = React.useState(false); + const [showPreferencesResetToast, setShowPreferencesResetToast] = useState(false); + + useEffect(() => { + let hideTimer: ReturnType | undefined; + + const showToastIfNeeded = () => { + if (!consumeHydrationResetToast()) { + return; + } + + setShowPreferencesResetToast(true); + hideTimer = setTimeout(() => { + setShowPreferencesResetToast(false); + }, 4000); + }; + + showToastIfNeeded(); + const unsubscribe = subscribeToHydrationResetToast(showToastIfNeeded); + + return () => { + unsubscribe(); + if (hideTimer) { + clearTimeout(hideTimer); + } + }; + }, []); useEffect(() => { async function prepareApp() { @@ -419,6 +472,7 @@ const App = () => { + {showPreferencesResetToast ? : null} ); diff --git a/src/__tests__/store/hydrationRecovery.test.ts b/src/__tests__/store/hydrationRecovery.test.ts new file mode 100644 index 0000000..d46604e --- /dev/null +++ b/src/__tests__/store/hydrationRecovery.test.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ + +jest.mock('../../utils/logger', () => ({ + appLogger: { + warn: jest.fn(), + }, + default: { + error: jest.fn(), + }, +})); + +jest.mock('../../services/sentryContext', () => ({ + sentryContextService: { + captureMessage: jest.fn(), + }, +})); + +const getAsyncStorage = () => + require('@react-native-async-storage/async-storage') as { + getItem: jest.Mock; + setItem: jest.Mock; + removeItem: jest.Mock; + }; + +const getLogger = () => + require('../../utils/logger') as { + appLogger: { + warn: jest.Mock; + }; + }; + +const getSentryContext = () => + require('../../services/sentryContext') as { + sentryContextService: { + captureMessage: jest.Mock; + }; + }; + +const flushHydration = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe('Zustand hydration recovery', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('resets ui store to defaults when persisted JSON is malformed', async () => { + getAsyncStorage().getItem.mockResolvedValue('{malformed-json'); + + const { useUiStore } = require('../../store/uiStore'); + await useUiStore.persist.rehydrate(); + await flushHydration(); + + expect(useUiStore.getState().theme).toBe('light'); + expect(getLogger().appLogger.warn).toHaveBeenCalledWith( + 'Zustand persisted store hydration failed; reset to defaults', + expect.objectContaining({ storeName: 'ui-storage' }) + ); + expect(getSentryContext().sentryContextService.captureMessage).toHaveBeenCalledWith( + 'Zustand persisted store hydration failed', + 'warning', + expect.objectContaining({ + tags: expect.objectContaining({ storeName: 'ui-storage' }), + }) + ); + }); + + it('resets settings store to defaults when persisted JSON is malformed', async () => { + getAsyncStorage().getItem.mockResolvedValue('{malformed-json'); + + const { useSettingsStore } = require('../../store/settingsStore'); + await useSettingsStore.persist.rehydrate(); + await flushHydration(); + + const state = useSettingsStore.getState(); + expect(state.profileVisibility).toBe('public'); + expect(state.analyticsEnabled).toBe(true); + expect(state.dataSaverEnabled).toBe(false); + expect(getLogger().appLogger.warn).toHaveBeenCalledWith( + 'Zustand persisted store hydration failed; reset to defaults', + expect.objectContaining({ storeName: 'settings-storage' }) + ); + expect(getSentryContext().sentryContextService.captureMessage).toHaveBeenCalledWith( + 'Zustand persisted store hydration failed', + 'warning', + expect.objectContaining({ + tags: expect.objectContaining({ storeName: 'settings-storage' }), + }) + ); + }); + + it('resets course progress store to defaults and queues one toast after malformed JSON', async () => { + getAsyncStorage().getItem.mockResolvedValue('{malformed-json'); + + const { useCourseProgressStore } = require('../../store/courseProgressStore'); + const { + consumeHydrationResetToast, + resetHydrationRecoveryForTests, + } = require('../../store/persistence'); + + resetHydrationRecoveryForTests(); + await useCourseProgressStore.persist.rehydrate(); + await flushHydration(); + + expect(useCourseProgressStore.getState().progressMap).toEqual({}); + expect(consumeHydrationResetToast()).toBe(true); + expect(consumeHydrationResetToast()).toBe(false); + expect(getLogger().appLogger.warn).toHaveBeenCalledWith( + 'Zustand persisted store hydration failed; reset to defaults', + expect.objectContaining({ storeName: 'course-progress-storage' }) + ); + expect(getSentryContext().sentryContextService.captureMessage).toHaveBeenCalledWith( + 'Zustand persisted store hydration failed', + 'warning', + expect.objectContaining({ + tags: expect.objectContaining({ storeName: 'course-progress-storage' }), + }) + ); + }); +}); diff --git a/src/components/ui/CachedImage.tsx b/src/components/ui/CachedImage.tsx index cb9ac9b..2883839 100644 --- a/src/components/ui/CachedImage.tsx +++ b/src/components/ui/CachedImage.tsx @@ -264,8 +264,6 @@ const CachedImageComponent: React.FC = ({ /> - - {/* Loading indicator overlay */} {isLoading && showLoadingIndicator && ( diff --git a/src/services/formCache.ts b/src/services/formCache.ts index ac05452..069ddb1 100644 --- a/src/services/formCache.ts +++ b/src/services/formCache.ts @@ -1,13 +1,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { safeStorageWrite } from '../utils/storage'; + /** Returns an AsyncStorage key scoped to the given user (versioned for future migrations). */ export function getFormCacheStorageKey(userId: string): string { return `@teachlink/form-cache/${userId}/v1`; } -/** @deprecated Use getFormCacheStorageKey(userId) instead. Kept for migration only. */ -import { safeStorageWrite } from '../utils/storage'; - /** AsyncStorage key for the form value cache (versioned for future migrations). */ export const FORM_CACHE_STORAGE_KEY = '@teachlink/form-cache/v1'; @@ -54,7 +53,7 @@ export function pruneExpiredCache(store: FormCacheStore, now = Date.now()): Form return pruned; } -export async function loadFormCache(storageKey: string): Promise { +export async function loadFormCache(storageKey = FORM_CACHE_STORAGE_KEY): Promise { const raw = await AsyncStorage.getItem(storageKey); if (!raw) return {}; try { @@ -69,10 +68,18 @@ export async function loadFormCache(storageKey: string): Promise } } -export async function saveFormCache(storageKey: string, store: FormCacheStore): Promise { - await AsyncStorage.setItem(storageKey, JSON.stringify(store)); -export async function saveFormCache(store: FormCacheStore): Promise { - await safeStorageWrite(FORM_CACHE_STORAGE_KEY, JSON.stringify(store)); +export async function saveFormCache(storageKey: string, store: FormCacheStore): Promise; +export async function saveFormCache(store: FormCacheStore): Promise; +export async function saveFormCache( + storageKeyOrStore: string | FormCacheStore, + maybeStore?: FormCacheStore +): Promise { + if (typeof storageKeyOrStore === 'string') { + await AsyncStorage.setItem(storageKeyOrStore, JSON.stringify(maybeStore ?? {})); + return; + } + + await safeStorageWrite(FORM_CACHE_STORAGE_KEY, JSON.stringify(storageKeyOrStore)); } export async function getCachedFieldValue( @@ -104,7 +111,18 @@ export async function setCachedFieldValue( storageKey: string, key: FormCacheFieldKey, value: string +): Promise; +export async function setCachedFieldValue(key: FormCacheFieldKey, value: string): Promise; +export async function setCachedFieldValue( + storageKeyOrKey: string, + keyOrValue: FormCacheFieldKey | string, + maybeValue?: string ): Promise { + const storageKey = maybeValue ? storageKeyOrKey : FORM_CACHE_STORAGE_KEY; + const key = maybeValue + ? (keyOrValue as FormCacheFieldKey) + : (storageKeyOrKey as FormCacheFieldKey); + const value = maybeValue ?? keyOrValue; const trimmed = value.trim(); if (!trimmed || SENSITIVE_FIELD_KEYS.includes(key)) return; diff --git a/src/store/achievementStore.ts b/src/store/achievementStore.ts index 5f4fdcf..5e73c71 100644 --- a/src/store/achievementStore.ts +++ b/src/store/achievementStore.ts @@ -1,10 +1,15 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { asyncStorageJSONStorage, isRecord, unwrapPersistedState } from './persistence'; +import { + asyncStorageJSONStorage, + createHydrationErrorRecovery, + isRecord, + unwrapPersistedState, +} from './persistence'; import { useReviewStore } from './reviewStore'; -import { inAppReviewService, ReviewTrigger } from '../services/inAppReview'; import apiService from '../services/api'; +import { inAppReviewService, ReviewTrigger } from '../services/inAppReview'; import { appLogger } from '../utils/logger'; const triggerAchievementReview = () => { @@ -263,13 +268,22 @@ function normalizeAchievementState(rawState: unknown): { }; } +const createInitialAchievementState = () => ({ + achievements: buildAchievementsFromProgress({}), + achievementProgress: {}, + unlockedCount: 0, + isLoaded: false, +}); + +let resetAchievementStoreAfterHydrationError = () => {}; + export const useAchievementStore = create()( persist( - (set, get) => ({ - achievements: buildAchievementsFromProgress({}), - achievementProgress: {}, - unlockedCount: 0, - isLoaded: false, + (set, get): AchievementState => { + resetAchievementStoreAfterHydrationError = () => set(createInitialAchievementState()); + + return { + ...createInitialAchievementState(), loadAchievements: () => { const { isLoaded, achievements } = get(); @@ -373,11 +387,16 @@ export const useAchievementStore = create()( achievementProgress: snapshotAchievementProgress(achievements), unlockedCount: achievements.filter(a => !a.isLocked).length, }), - }), + }; + }, { name: 'achievement-storage', version: 1, storage: asyncStorageJSONStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'achievement-storage', + resetAchievementStoreAfterHydrationError + ), partialize: state => ({ achievementProgress: state.achievementProgress, unlockedCount: state.unlockedCount, diff --git a/src/store/bookmarkStore.ts b/src/store/bookmarkStore.ts index 775fdce..82dec2e 100644 --- a/src/store/bookmarkStore.ts +++ b/src/store/bookmarkStore.ts @@ -1,7 +1,7 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; +import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; import { apiService } from '../services/api'; import { logger } from '../utils/logger'; @@ -31,59 +31,76 @@ async function courseExists(courseId: string): Promise { } } +const INITIAL_BOOKMARK_STATE = { + bookmarks: [], + isLoading: false, +}; + +let resetBookmarkStoreAfterHydrationError = () => {}; + export const useBookmarkStore = create()( persist( - (set, get) => ({ - bookmarks: [], - isLoading: false, + (set, get): BookmarkState => { + resetBookmarkStoreAfterHydrationError = () => set(INITIAL_BOOKMARK_STATE); - addBookmark: async item => { - if (item.itemType === 'course') { - const exists = await courseExists(item.itemId); - if (!exists) { - logger.warn('bookmarkStore: course not found, bookmark rejected', { - courseId: item.itemId, - }); - return; + return { + ...INITIAL_BOOKMARK_STATE, + + addBookmark: async item => { + if (item.itemType === 'course') { + const exists = await courseExists(item.itemId); + if (!exists) { + logger.warn('bookmarkStore: course not found, bookmark rejected', { + courseId: item.itemId, + }); + return; + } } - } - set(s => ({ bookmarks: [...s.bookmarks, item] })); - try { - await apiService.post('/api/bookmarks', { itemId: item.itemId, itemType: item.itemType }); - } catch (error: any) { - if (error.code !== 'ERR_NETWORK' && error.message !== 'Network Error') { - logger.error('bookmarkStore: addBookmark sync failed', error); + set(s => ({ bookmarks: [...s.bookmarks, item] })); + try { + await apiService.post('/api/bookmarks', { + itemId: item.itemId, + itemType: item.itemType, + }); + } catch (error: any) { + if (error.code !== 'ERR_NETWORK' && error.message !== 'Network Error') { + logger.error('bookmarkStore: addBookmark sync failed', error); + } } - } - }, + }, - removeBookmark: async itemId => { - set(s => ({ bookmarks: s.bookmarks.filter(b => b.itemId !== itemId) })); - try { - await apiService.delete(`/api/bookmarks/${itemId}`); - } catch (error: any) { - if (error.code !== 'ERR_NETWORK' && error.message !== 'Network Error') { - logger.error('bookmarkStore: removeBookmark sync failed', error); + removeBookmark: async itemId => { + set(s => ({ bookmarks: s.bookmarks.filter(b => b.itemId !== itemId) })); + try { + await apiService.delete(`/api/bookmarks/${itemId}`); + } catch (error: any) { + if (error.code !== 'ERR_NETWORK' && error.message !== 'Network Error') { + logger.error('bookmarkStore: removeBookmark sync failed', error); + } } - } - }, + }, - isBookmarked: itemId => get().bookmarks.some(b => b.itemId === itemId), + isBookmarked: itemId => get().bookmarks.some(b => b.itemId === itemId), - validateBookmarks: async () => { - const courseBookmarks = get().bookmarks.filter(b => b.itemType === 'course'); - for (const bookmark of courseBookmarks) { - const exists = await courseExists(bookmark.itemId); - if (!exists) { - logger.info('bookmarkStore: removing stale bookmark', { itemId: bookmark.itemId }); - set(s => ({ bookmarks: s.bookmarks.filter(b => b.itemId !== bookmark.itemId) })); + validateBookmarks: async () => { + const courseBookmarks = get().bookmarks.filter(b => b.itemType === 'course'); + for (const bookmark of courseBookmarks) { + const exists = await courseExists(bookmark.itemId); + if (!exists) { + logger.info('bookmarkStore: removing stale bookmark', { itemId: bookmark.itemId }); + set(s => ({ bookmarks: s.bookmarks.filter(b => b.itemId !== bookmark.itemId) })); + } } - } - }, - }), + }, + }; + }, { name: 'bookmarks', - storage: createJSONStorage(() => AsyncStorage), + storage: asyncStorageJSONStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'bookmarks', + resetBookmarkStoreAfterHydrationError + ), partialize: state => ({ bookmarks: state.bookmarks }), } ) diff --git a/src/store/courseProgressStore.ts b/src/store/courseProgressStore.ts index e6f9bcd..ebcba12 100644 --- a/src/store/courseProgressStore.ts +++ b/src/store/courseProgressStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { asyncStorageJSONStorage } from './persistence'; +import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; import type { CourseProgress, LessonProgress } from '../types/course'; @@ -19,59 +19,75 @@ interface CourseProgressState { isCourseComplete: (courseId: string, totalLessons: number) => boolean; } +const INITIAL_COURSE_PROGRESS_STATE = { + progressMap: {}, +}; + +let resetCourseProgressStoreAfterHydrationError = () => {}; + export const useCourseProgressStore = create()( persist( - (set, get) => ({ - progressMap: {}, + (set, get): CourseProgressState => { + resetCourseProgressStoreAfterHydrationError = () => set(INITIAL_COURSE_PROGRESS_STATE); + + return { + ...INITIAL_COURSE_PROGRESS_STATE, - setCourseProgress: (courseId, progress) => - set(s => ({ progressMap: { ...s.progressMap, [courseId]: progress } })), + setCourseProgress: (courseId, progress) => + set(s => ({ progressMap: { ...s.progressMap, [courseId]: progress } })), - getCourseProgress: courseId => get().progressMap[courseId] ?? null, + getCourseProgress: courseId => get().progressMap[courseId] ?? null, - markLessonComplete: (courseId, lessonId, totalLessons, lessonData) => { - set(s => { - const existing = s.progressMap[courseId]; - if (!existing) return s; + markLessonComplete: (courseId, lessonId, totalLessons, lessonData) => { + set(s => { + const existing = s.progressMap[courseId]; + if (!existing) return s; - const lessonProgress: LessonProgress = { - lessonId, - completed: true, - lastPosition: 0, - timeSpent: 0, - completedAt: new Date().toISOString(), - ...lessonData, - }; + const lessonProgress: LessonProgress = { + lessonId, + completed: true, + lastPosition: 0, + timeSpent: 0, + completedAt: new Date().toISOString(), + ...lessonData, + }; - const updatedLessons = { ...existing.lessons, [lessonId]: lessonProgress }; - const completedLessons = Object.values(updatedLessons).filter(l => l.completed).length; + const updatedLessons = { ...existing.lessons, [lessonId]: lessonProgress }; + const completedLessons = Object.values(updatedLessons).filter(l => l.completed).length; - // Use integer comparison as primary check; >= 99.5 as float fallback - const computedPercentage = totalLessons > 0 ? (completedLessons / totalLessons) * 100 : 0; - const isComplete = - completedLessons === totalLessons || computedPercentage >= 99.5; - const overallProgress = isComplete ? 100 : Math.min(99, Math.round(computedPercentage * 10) / 10); + // Use integer comparison as primary check; >= 99.5 as float fallback + const computedPercentage = + totalLessons > 0 ? (completedLessons / totalLessons) * 100 : 0; + const isComplete = completedLessons === totalLessons || computedPercentage >= 99.5; + const overallProgress = isComplete + ? 100 + : Math.min(99, Math.round(computedPercentage * 10) / 10); - return { - progressMap: { - ...s.progressMap, - [courseId]: { ...existing, lessons: updatedLessons, overallProgress }, - }, - }; - }); - }, + return { + progressMap: { + ...s.progressMap, + [courseId]: { ...existing, lessons: updatedLessons, overallProgress }, + }, + }; + }); + }, - isCourseComplete: (courseId, totalLessons) => { - const progress = get().progressMap[courseId]; - if (!progress) return false; - const completedLessons = Object.values(progress.lessons).filter(l => l.completed).length; - return completedLessons === totalLessons || progress.overallProgress >= 99.5; - }, - }), + isCourseComplete: (courseId, totalLessons) => { + const progress = get().progressMap[courseId]; + if (!progress) return false; + const completedLessons = Object.values(progress.lessons).filter(l => l.completed).length; + return completedLessons === totalLessons || progress.overallProgress >= 99.5; + }, + }; + }, { name: 'course-progress-storage', version: 1, storage: asyncStorageJSONStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'course-progress-storage', + resetCourseProgressStoreAfterHydrationError + ), partialize: state => ({ progressMap: state.progressMap, }), diff --git a/src/store/degradationStore.ts b/src/store/degradationStore.ts index e8d392f..e778f08 100644 --- a/src/store/degradationStore.ts +++ b/src/store/degradationStore.ts @@ -21,12 +21,12 @@ * the store starts fresh with correct defaults. */ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; -import { FeatureStatus, FeatureType } from '../services/featureCapabilities'; +import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; import { useFeatureFlagStore } from './featureFlagStore'; +import { FeatureStatus, FeatureType } from '../services/featureCapabilities'; export interface DegradationNotification { id: string; @@ -87,6 +87,19 @@ const DEFAULT_PREFERENCES: DegradationPreferences = { let notificationIdCounter = 0; +const createInitialDegradationState = () => ({ + degradedFeatures: [], + featureStatuses: { + [FeatureType.CAMERA]: FeatureStatus.UNAVAILABLE, + [FeatureType.PUSH_NOTIFICATIONS]: FeatureStatus.UNAVAILABLE, + [FeatureType.LOCATION]: FeatureStatus.AVAILABLE, + }, + notifications: [], + preferences: DEFAULT_PREFERENCES, +}); + +let resetDegradationStoreAfterHydrationError = () => {}; + /** * Convert the stored `degradedFeatures` array to a Set for O(1) membership * tests. This is the canonical read-time selector; it does not mutate state. @@ -99,17 +112,13 @@ export function selectDegradedFeaturesSet( export const useDegradationStore = create()( persist( - (set, get) => ({ + (set, get): DegradationState => { + resetDegradationStoreAfterHydrationError = () => set(createInitialDegradationState()); + + return { // Initial state // Stored as FeatureType[] — not Set — to survive JSON round-trips. - degradedFeatures: [], - featureStatuses: { - [FeatureType.CAMERA]: FeatureStatus.UNAVAILABLE, - [FeatureType.PUSH_NOTIFICATIONS]: FeatureStatus.UNAVAILABLE, - [FeatureType.LOCATION]: FeatureStatus.AVAILABLE, - }, - notifications: [], - preferences: DEFAULT_PREFERENCES, + ...createInitialDegradationState(), // Feature status actions setFeatureStatus: (feature, status) => @@ -224,10 +233,15 @@ export const useDegradationStore = create()( preferences: { ...state.preferences, respectRemoteFlags: respect }, })); }, - }), + }; + }, { name: 'degradation-store', - storage: createJSONStorage(() => AsyncStorage), + storage: asyncStorageJSONStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'degradation-store', + resetDegradationStoreAfterHydrationError + ), /** * Version 2: bumped from 1 (implicit) to discard any previously-persisted * state where `degradedFeatures` was serialised as `{}` (empty object) diff --git a/src/store/index.ts b/src/store/index.ts index 8d5651f..8a59902 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { createJSONStorage, devtools, persist, subscribeWithSelector } from 'zustand/middleware'; +import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'; -import { secureStorageJSONStorage, toUnixMs } from './persistence'; +import { createHydrationErrorRecovery, secureStorageJSONStorage, toUnixMs } from './persistence'; import { sentryContextService } from '../services/sentryContext'; export interface User { @@ -35,21 +35,31 @@ interface AppState { setError: (error: string | null) => void; } +const INITIAL_APP_STATE = { + user: null, + isAuthenticated: false, + isAuthLoading: false, + authError: null, + accessToken: null, + refreshToken: null, + sessionExpiresAt: null, + sessionExpiringSoon: false, + theme: 'light' as const, + isLoading: false, + error: null, +}; + +let resetAppStoreAfterHydrationError = () => {}; + export const useAppStore = create()( devtools( persist( - subscribeWithSelector(set => ({ - user: null, - isAuthenticated: false, - isAuthLoading: false, - authError: null, - accessToken: null, - refreshToken: null, - sessionExpiresAt: null, - sessionExpiringSoon: false, - theme: 'light', - isLoading: false, - error: null, + subscribeWithSelector(set => { + resetAppStoreAfterHydrationError = () => + set(INITIAL_APP_STATE, false, 'hydrationErrorReset'); + + return { + ...INITIAL_APP_STATE, setUser: user => { set({ user, isAuthenticated: !!user }, false, 'setUser'); // Sync Sentry scope with the signed-in user so every subsequent @@ -101,10 +111,15 @@ export const useAppStore = create()( }, setLoading: isLoading => set({ isLoading }, false, 'setLoading'), setError: error => set({ error }, false, 'setError'), - })), + }; + }), { name: 'app-auth-storage', storage: secureStorageJSONStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'app-auth-storage', + resetAppStoreAfterHydrationError + ), /** * Only persist auth-related and UI preference state. * Transient flags (isLoading, isAuthLoading, error, authError) diff --git a/src/store/notificationStore.ts b/src/store/notificationStore.ts index fbb198c..559c00e 100644 --- a/src/store/notificationStore.ts +++ b/src/store/notificationStore.ts @@ -1,7 +1,7 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; +import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; import { DEFAULT_NOTIFICATION_PREFERENCES, NotificationData, @@ -61,22 +61,31 @@ interface NotificationState { isNotificationTypeEnabled: (type: NotificationType) => boolean; } +const createInitialNotificationState = () => ({ + pushToken: null, + isTokenRegistered: false, + tokenLastUpdated: null, + hasPromptedForPermission: false, + permissionDeniedAt: null, + showNotificationExplainer: false, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + notifications: [], + unreadCount: 0, + notificationHistory: [], + lastEngagedAt: null, + lastNotificationSentAtByType: {}, +}); + +let resetNotificationStoreAfterHydrationError = () => {}; + export const useNotificationStore = create()( persist( - (set, get) => ({ + (set, get): NotificationState => { + resetNotificationStoreAfterHydrationError = () => set(createInitialNotificationState()); + + return { // Initial state - pushToken: null, - isTokenRegistered: false, - tokenLastUpdated: null, - hasPromptedForPermission: false, - permissionDeniedAt: null, - showNotificationExplainer: false, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - notifications: [], - unreadCount: 0, - notificationHistory: [], - lastEngagedAt: null, - lastNotificationSentAtByType: {}, + ...createInitialNotificationState(), // Push token actions setPushToken: token => @@ -281,14 +290,18 @@ export const useNotificationStore = create()( return true; } }, - }), + }; + }, { name: 'notification-storage', - storage: createJSONStorage(() => AsyncStorage), + storage: asyncStorageJSONStorage, version: 2, migrate: (persistedState, version) => { if (version < 2) { - const state = JSON.parse(persistedState as string) as any; + const state = + typeof persistedState === 'string' + ? (JSON.parse(persistedState) as any) + : { ...(persistedState as any) }; // Convert notification timestamps if (Array.isArray(state.notifications)) { state.notifications = state.notifications.map((n: any) => ({ @@ -315,6 +328,10 @@ export const useNotificationStore = create()( } return persistedState; }, + onRehydrateStorage: createHydrationErrorRecovery( + 'notification-storage', + resetNotificationStoreAfterHydrationError + ), partialize: state => ({ // Only persist these fields pushToken: state.pushToken, diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 970697b..cdbb0ec 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -2,6 +2,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import * as SecureStore from 'expo-secure-store'; import { createJSONStorage, type StateStorage } from 'zustand/middleware'; +import { sentryContextService } from '../services/sentryContext'; +import { appLogger } from '../utils/logger'; + export interface VersionedEnvelope { version: number; data: T; @@ -9,7 +12,7 @@ export interface VersionedEnvelope { export const asyncStorageJSONStorage = createJSONStorage(() => AsyncStorage); -const secureStorageAdapter: StateStorage = { +export const secureStorageAdapter: StateStorage = { getItem: async (name: string) => { const value = await SecureStore.getItemAsync(name); return value ?? null; @@ -24,6 +27,70 @@ const secureStorageAdapter: StateStorage = { export const secureStorageJSONStorage = createJSONStorage(() => secureStorageAdapter); +type HydrationReset = () => void; + +let hydrationResetToastPending = false; +let hydrationResetToastShown = false; +const hydrationResetToastListeners = new Set<() => void>(); + +export function subscribeToHydrationResetToast(listener: () => void): () => void { + hydrationResetToastListeners.add(listener); + return () => { + hydrationResetToastListeners.delete(listener); + }; +} + +export function consumeHydrationResetToast(): boolean { + if (!hydrationResetToastPending || hydrationResetToastShown) { + return false; + } + + hydrationResetToastPending = false; + hydrationResetToastShown = true; + return true; +} + +export function resetHydrationRecoveryForTests(): void { + hydrationResetToastPending = false; + hydrationResetToastShown = false; + hydrationResetToastListeners.clear(); +} + +function notifyHydrationResetToast(): void { + if (hydrationResetToastShown || hydrationResetToastPending) { + return; + } + + hydrationResetToastPending = true; + hydrationResetToastListeners.forEach(listener => listener()); +} + +export function createHydrationErrorRecovery(storeName: string, resetStore: HydrationReset) { + return () => (_state: unknown, error: unknown) => { + if (!error) { + return; + } + + resetStore(); + appLogger.warn('Zustand persisted store hydration failed; reset to defaults', { + storeName, + error: error instanceof Error ? error.message : String(error), + }); + sentryContextService.captureMessage('Zustand persisted store hydration failed', 'warning', { + tags: { + storeName, + 'store.hydration': 'failed', + }, + extra: { + storeName, + error: error instanceof Error ? error.message : String(error), + }, + fingerprint: ['zustand-hydration-failure', storeName], + }); + notifyHydrationResetToast(); + }; +} + export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/src/store/reviewStore.ts b/src/store/reviewStore.ts index 2dbbb3f..6c12536 100644 --- a/src/store/reviewStore.ts +++ b/src/store/reviewStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { asyncStorageJSONStorage } from './persistence'; +import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; import { ReviewTrigger } from '../services/inAppReview'; /** @@ -49,11 +49,7 @@ interface ReviewState { // Actions /** Record a review request attempt */ - recordReviewRequest: ( - trigger: ReviewTrigger, - shown: boolean, - reason: string - ) => void; + recordReviewRequest: (trigger: ReviewTrigger, shown: boolean, reason: string) => void; /** Increment courses completed */ incrementCoursesCompleted: () => void; /** Increment session count */ @@ -79,131 +75,143 @@ interface ReviewState { }; } +const createInitialReviewState = () => ({ + installDate: Date.now(), + lastReviewRequestDate: null, + reviewRequestCount: 0, + reviewRequestsThisYear: 0, + lastRequestYear: null, + doNotAskAgain: false, + requestHistory: [], + coursesCompleted: 0, + sessionCount: 0, + achievementsUnlocked: 0, + learningStreak: 0, + perfectQuizScores: 0, +}); + +let resetReviewStoreAfterHydrationError = () => {}; + /** * Review store manages all metrics and preferences related to in-app reviews. - * + * * This store tracks: * - User engagement metrics (courses, sessions, achievements) * - Review request history and timing * - User preferences (opt-out) - * + * * Usage: * ```typescript * import { useReviewStore } from '@/store/reviewStore'; - * + * * const { incrementCoursesCompleted, getMetrics } = useReviewStore(); - * + * * // After user completes a course * incrementCoursesCompleted(); - * + * * // Get metrics for review eligibility * const metrics = getMetrics(); * ``` */ export const useReviewStore = create()( persist( - (set, get) => ({ - // Initial state - installDate: Date.now(), - lastReviewRequestDate: null, - reviewRequestCount: 0, - reviewRequestsThisYear: 0, - lastRequestYear: null, - doNotAskAgain: false, - requestHistory: [], - coursesCompleted: 0, - sessionCount: 0, - achievementsUnlocked: 0, - learningStreak: 0, - perfectQuizScores: 0, - - // Actions - recordReviewRequest: (trigger, shown, reason) => { - const now = Date.now(); - const currentYear = new Date(now).getFullYear(); - const state = get(); - - // Reset yearly counter if it's a new year - const reviewRequestsThisYear = - state.lastRequestYear === currentYear - ? state.reviewRequestsThisYear + 1 - : 1; - - set({ - lastReviewRequestDate: now, - reviewRequestCount: state.reviewRequestCount + 1, - reviewRequestsThisYear, - lastRequestYear: currentYear, - requestHistory: [ - ...state.requestHistory, - { - timestamp: now, - trigger, - shown, - reason, - }, - ].slice(-20), // Keep only last 20 entries - }); - }, - - incrementCoursesCompleted: () => - set((state) => ({ - coursesCompleted: state.coursesCompleted + 1, - })), - - incrementSessionCount: () => - set((state) => ({ - sessionCount: state.sessionCount + 1, - })), - - incrementAchievementsUnlocked: () => - set((state) => ({ - achievementsUnlocked: state.achievementsUnlocked + 1, - })), - - setLearningStreak: (streak) => - set({ - learningStreak: streak, - }), - - incrementPerfectQuizScores: () => - set((state) => ({ - perfectQuizScores: state.perfectQuizScores + 1, - })), - - setDoNotAskAgain: (value) => - set({ - doNotAskAgain: value, - }), - - resetReviewMetrics: () => - set({ - lastReviewRequestDate: null, - reviewRequestCount: 0, - reviewRequestsThisYear: 0, - lastRequestYear: null, - doNotAskAgain: false, - requestHistory: [], - }), - - getMetrics: () => { - const state = get(); - return { - installDate: state.installDate, - lastReviewRequestDate: state.lastReviewRequestDate, - reviewRequestCount: state.reviewRequestCount, - coursesCompleted: state.coursesCompleted, - sessionCount: state.sessionCount, - doNotAskAgain: state.doNotAskAgain, - }; - }, - }), + (set, get): ReviewState => { + resetReviewStoreAfterHydrationError = () => set(createInitialReviewState()); + + return { + // Initial state + ...createInitialReviewState(), + + // Actions + recordReviewRequest: (trigger, shown, reason) => { + const now = Date.now(); + const currentYear = new Date(now).getFullYear(); + const state = get(); + + // Reset yearly counter if it's a new year + const reviewRequestsThisYear = + state.lastRequestYear === currentYear ? state.reviewRequestsThisYear + 1 : 1; + + set({ + lastReviewRequestDate: now, + reviewRequestCount: state.reviewRequestCount + 1, + reviewRequestsThisYear, + lastRequestYear: currentYear, + requestHistory: [ + ...state.requestHistory, + { + timestamp: now, + trigger, + shown, + reason, + }, + ].slice(-20), // Keep only last 20 entries + }); + }, + + incrementCoursesCompleted: () => + set(state => ({ + coursesCompleted: state.coursesCompleted + 1, + })), + + incrementSessionCount: () => + set(state => ({ + sessionCount: state.sessionCount + 1, + })), + + incrementAchievementsUnlocked: () => + set(state => ({ + achievementsUnlocked: state.achievementsUnlocked + 1, + })), + + setLearningStreak: streak => + set({ + learningStreak: streak, + }), + + incrementPerfectQuizScores: () => + set(state => ({ + perfectQuizScores: state.perfectQuizScores + 1, + })), + + setDoNotAskAgain: value => + set({ + doNotAskAgain: value, + }), + + resetReviewMetrics: () => + set({ + lastReviewRequestDate: null, + reviewRequestCount: 0, + reviewRequestsThisYear: 0, + lastRequestYear: null, + doNotAskAgain: false, + requestHistory: [], + }), + + getMetrics: () => { + const state = get(); + return { + installDate: state.installDate, + lastReviewRequestDate: state.lastReviewRequestDate, + reviewRequestCount: state.reviewRequestCount, + coursesCompleted: state.coursesCompleted, + sessionCount: state.sessionCount, + doNotAskAgain: state.doNotAskAgain, + }; + }, + }; + }, { name: 'review-storage', version: 1, storage: asyncStorageJSONStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'review-storage', + resetReviewStoreAfterHydrationError + ), // Persist everything except the getMetrics function - partialize: (state) => ({ + partialize: state => ({ installDate: state.installDate, lastReviewRequestDate: state.lastReviewRequestDate, reviewRequestCount: state.reviewRequestCount, @@ -226,23 +234,23 @@ export const useReviewStore = create()( */ /** Get review metrics for eligibility check */ -export const useReviewMetrics = () => useReviewStore((state) => state.getMetrics()); +export const useReviewMetrics = () => useReviewStore(state => state.getMetrics()); /** Get courses completed count */ -export const useCoursesCompleted = () => useReviewStore((state) => state.coursesCompleted); +export const useCoursesCompleted = () => useReviewStore(state => state.coursesCompleted); /** Get session count */ -export const useSessionCount = () => useReviewStore((state) => state.sessionCount); +export const useSessionCount = () => useReviewStore(state => state.sessionCount); /** Get "Don't ask again" preference */ -export const useDoNotAskAgain = () => useReviewStore((state) => state.doNotAskAgain); +export const useDoNotAskAgain = () => useReviewStore(state => state.doNotAskAgain); /** Get review request history */ -export const useReviewHistory = () => useReviewStore((state) => state.requestHistory); +export const useReviewHistory = () => useReviewStore(state => state.requestHistory); /** Get all review actions */ export const useReviewActions = () => - useReviewStore((state) => ({ + useReviewStore(state => ({ recordReviewRequest: state.recordReviewRequest, incrementCoursesCompleted: state.incrementCoursesCompleted, incrementSessionCount: state.incrementSessionCount, diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 3351c82..66c78f9 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -1,6 +1,7 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; +import { persist, type PersistStorage } from 'zustand/middleware'; + +import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; export type ProfileVisibility = 'public' | 'private' | 'friends_only'; export type DownloadQuality = 'low' | 'medium' | 'high'; @@ -59,42 +60,6 @@ interface SettingsState { resetSettings: () => void; } -const DEFAULT_SETTINGS: Omit< - SettingsState, - | 'setProfileVisibility' - | 'setTwoFactorEnabled' - | 'setDataSharing' - | 'setAnalyticsEnabled' - | 'setLocationServices' - | 'setDownloadOverWifiOnly' - | 'setAutoDownload' - | 'setDownloadQuality' - | 'setStorageLimit' - | 'setLanguage' - | 'setFontSize' - | 'setAutoplay' - | 'setHapticFeedback' - | 'setAdaptiveThemeEnabled' - | 'setDataSaverEnabled' - | 'resetSettings' -> = { - 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, - dataSaverEnabled: false, -}; - const INITIAL_STATE = { profileVisibility: 'public' as ProfileVisibility, twoFactorEnabled: false, @@ -113,40 +78,52 @@ const INITIAL_STATE = { dataSaverEnabled: false, }; +type PersistedSettingsState = typeof INITIAL_STATE; + +let resetSettingsStoreAfterHydrationError = () => {}; + export const useSettingsStore = create()( - persist( - set => ({ - ...INITIAL_STATE, - - // Account - 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 }), - - // Downloads - 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 }), - setDataSaverEnabled: (v) => set({ dataSaverEnabled: v }), - - resetSettings: () => set(INITIAL_STATE), - }), + persist( + set => { + resetSettingsStoreAfterHydrationError = () => set(INITIAL_STATE); + + return { + ...INITIAL_STATE, + + // Account + 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 }), + + // Downloads + 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 }), + setDataSaverEnabled: v => set({ dataSaverEnabled: v }), + + resetSettings: () => set(INITIAL_STATE), + }; + }, { name: 'settings-storage', version: 1, - storage: createJSONStorage(() => AsyncStorage), + storage: asyncStorageJSONStorage as PersistStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'settings-storage', + resetSettingsStoreAfterHydrationError + ), partialize: state => ({ profileVisibility: state.profileVisibility, twoFactorEnabled: state.twoFactorEnabled, @@ -164,7 +141,6 @@ export const useSettingsStore = create()( adaptiveThemeEnabled: state.adaptiveThemeEnabled, dataSaverEnabled: state.dataSaverEnabled, }), - migrate: persistedState => (persistedState ?? {}) as Partial, } ) ); diff --git a/src/store/uiStore.ts b/src/store/uiStore.ts index 9a34ab1..a5ade95 100644 --- a/src/store/uiStore.ts +++ b/src/store/uiStore.ts @@ -1,21 +1,36 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; + +import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; interface UiState { theme: 'light' | 'dark'; setTheme: (theme: 'light' | 'dark') => void; } +const INITIAL_UI_STATE = { + theme: 'light' as const, +}; + +let resetUiStoreAfterHydrationError = () => {}; + export const useUiStore = create()( persist( - (set) => ({ - theme: 'light', - setTheme: (theme) => set({ theme }), - }), + (set): UiState => { + resetUiStoreAfterHydrationError = () => set(INITIAL_UI_STATE); + + return { + ...INITIAL_UI_STATE, + setTheme: theme => set({ theme }), + }; + }, { name: 'ui-storage', - storage: createJSONStorage(() => AsyncStorage), + storage: asyncStorageJSONStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'ui-storage', + resetUiStoreAfterHydrationError + ), } ) );