From 99dfa5926345ef7e0f0a27b2ef7d5d76db8700d2 Mon Sep 17 00:00:00 2001 From: Jerry_tekh Date: Sat, 27 Jun 2026 19:10:28 +0100 Subject: [PATCH 1/2] Add Zustand hydration recovery --- App.tsx | 58 ++- src/__tests__/store/hydrationRecovery.test.ts | 123 +++++ src/store/achievementStore.ts | 217 +++++---- src/store/bookmarkStore.ts | 84 ++-- src/store/courseProgressStore.ts | 28 +- src/store/degradationStore.ts | 232 ++++----- src/store/index.ts | 148 +++--- src/store/notificationStore.ts | 458 +++++++++--------- src/store/persistence.ts | 69 ++- src/store/reviewStore.ts | 236 ++++----- src/store/settingsStore.ts | 102 ++-- src/store/uiStore.ts | 29 +- 12 files changed, 1071 insertions(+), 713 deletions(-) create mode 100644 src/__tests__/store/hydrationRecovery.test.ts diff --git a/App.tsx b/App.tsx index d00a2650..32235061 100644 --- a/App.tsx +++ b/App.tsx @@ -18,7 +18,6 @@ import { ErrorBoundary } from './src/components/common/ErrorBoundary'; import { initializeLogging } from './src/config/logging'; import { AuthProvider, useAdaptiveTheme, useReviewMetrics } from './src/hooks'; import AppNavigator from './src/navigation/AppNavigator'; -import { setupNotificationNavigation } from './src/navigation/linking'; import { apiClient, getCacheStatus, @@ -29,7 +28,6 @@ import { warmCriticalCaches } from './src/services/cacheWarming'; import { crashReportingService } from './src/services/crashReporting'; import { featureCapabilities } from './src/services/featureCapabilities'; import { inAppReviewService } from './src/services/inAppReview'; -import { initializeSecureStorage } from './src/services/secureStorage'; import { mobileAuthService } from './src/services/mobileAuth'; import { registerForPushNotifications, // Added missing native push helpers @@ -38,11 +36,15 @@ import { } from './src/services/pushNotifications'; import { requestQueue } from './src/services/requestQueue'; import { searchIndexService } from './src/services/searchIndex'; -import { initializeSecureStorage } from './src/services/secureStorage'; // Added missing storage helper mock path +import { initializeSecureStorage } from './src/services/secureStorage'; 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'; @@ -118,6 +120,29 @@ const CacheRevalidationBanner = () => { ); }; +const PreferencesResetToast = () => ( + + Your preferences were reset + +); + let _compromisedAlertShown = false; function showCompromisedAlert(): void { @@ -139,6 +164,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() { @@ -393,6 +444,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 00000000..d46604e8 --- /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/store/achievementStore.ts b/src/store/achievementStore.ts index 20b7ea3f..a58bf02d 100644 --- a/src/store/achievementStore.ts +++ b/src/store/achievementStore.ts @@ -1,16 +1,24 @@ 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'; const triggerAchievementReview = () => { - const { incrementAchievementsUnlocked, getMetrics, recordReviewRequest } = useReviewStore.getState(); + const { incrementAchievementsUnlocked, getMetrics, recordReviewRequest } = + useReviewStore.getState(); incrementAchievementsUnlocked(); - inAppReviewService.requestReview(ReviewTrigger.ACHIEVEMENT_UNLOCKED, getMetrics()).then((result) => { - recordReviewRequest(ReviewTrigger.ACHIEVEMENT_UNLOCKED, result.shown, result.reason); - }); + inAppReviewService + .requestReview(ReviewTrigger.ACHIEVEMENT_UNLOCKED, getMetrics()) + .then(result => { + recordReviewRequest(ReviewTrigger.ACHIEVEMENT_UNLOCKED, result.shown, result.reason); + }); }; export type BadgeRarity = 'common' | 'rare' | 'epic' | 'legendary'; @@ -42,6 +50,7 @@ interface AchievementState { achievements: Achievement[]; achievementProgress: Record; unlockedCount: number; + isLoaded: boolean; loadAchievements: () => void; unlockAchievement: (id: string) => void; updateProgress: (id: string, current: number) => void; @@ -260,110 +269,124 @@ function normalizeAchievementState(rawState: unknown): { }; } -export const useAchievementStore = create()( - persist( - (set, get) => ({ - achievements: buildAchievementsFromProgress({}), - achievementProgress: {}, - unlockedCount: 0, - isLoaded: false, - - loadAchievements: () => { - const { isLoaded, achievements } = get(); - if (isLoaded) return; - set({ - achievements: achievements.length > 0 ? achievements : DEFAULT_ACHIEVEMENTS, - isLoaded: true, - }); - }, +const createInitialAchievementState = () => ({ + achievements: buildAchievementsFromProgress({}), + achievementProgress: {}, + unlockedCount: 0, + isLoaded: false, +}); - unlockAchievement: (id: string) => - set(state => { - const achievement = state.achievements.find(a => a.id === id); - if (!achievement || !achievement.isLocked) return state; +let resetAchievementStoreAfterHydrationError = () => {}; - const updatedAchievements = state.achievements.map(a => - a.id === id - ? { +export const useAchievementStore = create()( + persist( + (set, get): AchievementState => { + resetAchievementStoreAfterHydrationError = () => set(createInitialAchievementState()); + + return { + ...createInitialAchievementState(), + + loadAchievements: () => { + const { isLoaded, achievements } = get(); + if (isLoaded) return; + set({ + achievements: achievements.length > 0 ? achievements : DEFAULT_ACHIEVEMENTS, + isLoaded: true, + }); + }, + + unlockAchievement: (id: string) => + set(state => { + const achievement = state.achievements.find(a => a.id === id); + if (!achievement || !achievement.isLocked) return state; + + const updatedAchievements = state.achievements.map(a => + a.id === id + ? { + ...a, + isLocked: false, + unlockedAt: new Date().toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }), + } + : a + ); + + setTimeout(triggerAchievementReview, 500); + + return { + achievements: updatedAchievements, + achievementProgress: snapshotAchievementProgress(updatedAchievements), + unlockedCount: updatedAchievements.filter(a => !a.isLocked).length, + }; + }), + + updateProgress: (id: string, current: number) => + set(state => { + const achievement = state.achievements.find(a => a.id === id); + if (!achievement || !achievement.isLocked) return state; + + const updatedAchievements = state.achievements.map(a => { + if (a.id !== id) return a; + + const progress = a.progress ? { ...a.progress, current } : { current, total: 1 }; + + if (progress.current >= progress.total) { + setTimeout(triggerAchievementReview, 500); + return { ...a, isLocked: false, unlockedAt: new Date().toLocaleDateString('en-US', { month: 'short', year: 'numeric', }), - } - : a - ); - - setTimeout(triggerAchievementReview, 500); - - return { - achievements: updatedAchievements, - achievementProgress: snapshotAchievementProgress(updatedAchievements), - unlockedCount: updatedAchievements.filter(a => !a.isLocked).length, - }; - }), - - updateProgress: (id: string, current: number) => - set(state => { - const achievement = state.achievements.find(a => a.id === id); - if (!achievement || !achievement.isLocked) return state; - - const updatedAchievements = state.achievements.map(a => { - if (a.id !== id) return a; - - const progress = a.progress ? { ...a.progress, current } : { current, total: 1 }; - - if (progress.current >= progress.total) { - setTimeout(triggerAchievementReview, 500); - return { - ...a, - isLocked: false, - unlockedAt: new Date().toLocaleDateString('en-US', { - month: 'short', - year: 'numeric', - }), - progress, - }; - } - - return { ...a, progress }; - }); - - return { - achievements: updatedAchievements, - achievementProgress: snapshotAchievementProgress(updatedAchievements), - unlockedCount: updatedAchievements.filter(a => !a.isLocked).length, - }; - }), - - isAchievementUnlocked: (id: string) => { - const achievement = get().achievements.find(a => a.id === id); - return achievement ? !achievement.isLocked : false; - }, - - getUnlockedAchievements: () => { - return get().achievements.filter(a => !a.isLocked); - }, - - resetAchievements: () => - set({ - achievements: buildAchievementsFromProgress({}), - achievementProgress: {}, - unlockedCount: 0, - }), - - initializeAchievements: (achievements: Achievement[]) => - set({ - achievements, - achievementProgress: snapshotAchievementProgress(achievements), - unlockedCount: achievements.filter(a => !a.isLocked).length, - }), - }), + progress, + }; + } + + return { ...a, progress }; + }); + + return { + achievements: updatedAchievements, + achievementProgress: snapshotAchievementProgress(updatedAchievements), + unlockedCount: updatedAchievements.filter(a => !a.isLocked).length, + }; + }), + + isAchievementUnlocked: (id: string) => { + const achievement = get().achievements.find(a => a.id === id); + return achievement ? !achievement.isLocked : false; + }, + + getUnlockedAchievements: () => { + return get().achievements.filter(a => !a.isLocked); + }, + + resetAchievements: () => + set({ + achievements: buildAchievementsFromProgress({}), + achievementProgress: {}, + unlockedCount: 0, + }), + + initializeAchievements: (achievements: Achievement[]) => + set({ + achievements, + 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 b8a51eb4..bf8b0342 100644 --- a/src/store/bookmarkStore.ts +++ b/src/store/bookmarkStore.ts @@ -1,10 +1,9 @@ -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 apiService from '../services/api'; -import logger from '../utils/logger'; +import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; +import { apiService } from '../services/api'; +import { logger } from '../utils/logger'; export interface BookmarkItem { itemId: string; @@ -21,40 +20,57 @@ interface BookmarkState { isBookmarked: (itemId: string) => boolean; } +const INITIAL_BOOKMARK_STATE = { + bookmarks: [], + isLoading: false, +}; + +let resetBookmarkStoreAfterHydrationError = () => {}; + export const useBookmarkStore = create()( persist( - (set, get) => ({ - bookmarks: [], - isLoading: false, - - addBookmark: async (item) => { - 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, get): BookmarkState => { + resetBookmarkStoreAfterHydrationError = () => set(INITIAL_BOOKMARK_STATE); + + return { + ...INITIAL_BOOKMARK_STATE, + + addBookmark: async item => { + 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), + }; + }, { name: 'bookmarks', - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ bookmarks: state.bookmarks }), - }, - ), + storage: asyncStorageJSONStorage, + onRehydrateStorage: createHydrationErrorRecovery( + 'bookmarks', + resetBookmarkStoreAfterHydrationError + ), + partialize: state => ({ bookmarks: state.bookmarks }), + } + ) ); diff --git a/src/store/courseProgressStore.ts b/src/store/courseProgressStore.ts index e407739e..b8226dc0 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 } from '../types/course'; @@ -12,20 +12,34 @@ interface CourseProgressState { getCourseProgress: (courseId: string) => CourseProgress | null; } +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, + }; + }, { 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 f64bad80..a77e0d31 100644 --- a/src/store/degradationStore.ts +++ b/src/store/degradationStore.ts @@ -21,10 +21,10 @@ * 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 { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; import { FeatureStatus, FeatureType } from '../services/featureCapabilities'; export interface DegradationNotification { @@ -83,6 +83,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. @@ -95,119 +108,122 @@ export function selectDegradedFeaturesSet( export const useDegradationStore = create()( persist( - (set, get) => ({ - // 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, - - // Feature status actions - setFeatureStatus: (feature, status) => - set(state => { - const isDegraded = + (set, get): DegradationState => { + resetDegradationStoreAfterHydrationError = () => set(createInitialDegradationState()); + + return { + // Initial state + // Stored as FeatureType[] — not Set — to survive JSON round-trips. + ...createInitialDegradationState(), + + // Feature status actions + setFeatureStatus: (feature, status) => + set(state => { + const isDegraded = + status === FeatureStatus.PERMISSION_DENIED || + status === FeatureStatus.HARDWARE_UNAVAILABLE || + status === FeatureStatus.UNAVAILABLE; + + // Use a Set for deduplication, then spread back to array for + // JSON-serialisability (Set -> {} under JSON.stringify). + const updatedSet = new Set(state.degradedFeatures); + if (isDegraded) { + updatedSet.add(feature); + } else { + updatedSet.delete(feature); + } + + return { + degradedFeatures: [...updatedSet], + featureStatuses: { + ...state.featureStatuses, + [feature]: status, + }, + }; + }), + + isFeatureDegraded: (feature: FeatureType): boolean => { + const status = get().featureStatuses[feature]; + return ( status === FeatureStatus.PERMISSION_DENIED || status === FeatureStatus.HARDWARE_UNAVAILABLE || - status === FeatureStatus.UNAVAILABLE; - - // Use a Set for deduplication, then spread back to array for - // JSON-serialisability (Set -> {} under JSON.stringify). - const updatedSet = new Set(state.degradedFeatures); - if (isDegraded) { - updatedSet.add(feature); - } else { - updatedSet.delete(feature); + status === FeatureStatus.UNAVAILABLE + ); + }, + + getDegradedFeatures: (): FeatureType[] => { + const features: FeatureType[] = []; + for (const feature of Object.values(FeatureType)) { + if (get().isFeatureDegraded(feature as FeatureType)) { + features.push(feature as FeatureType); + } } - - return { - degradedFeatures: [...updatedSet], - featureStatuses: { - ...state.featureStatuses, - [feature]: status, - }, + return features; + }, + + // Notification actions + addNotification: ( + notification: Omit + ): string => { + const id = `notif_${++notificationIdCounter}_${Date.now()}`; + const newNotification: DegradationNotification = { + ...notification, + id, + showedAt: new Date().toISOString(), }; - }), - - isFeatureDegraded: (feature: FeatureType): boolean => { - const status = get().featureStatuses[feature]; - return ( - status === FeatureStatus.PERMISSION_DENIED || - status === FeatureStatus.HARDWARE_UNAVAILABLE || - status === FeatureStatus.UNAVAILABLE - ); - }, - - getDegradedFeatures: (): FeatureType[] => { - const features: FeatureType[] = []; - for (const feature of Object.values(FeatureType)) { - if (get().isFeatureDegraded(feature as FeatureType)) { - features.push(feature as FeatureType); - } - } - return features; - }, - - // Notification actions - addNotification: (notification: Omit): string => { - const id = `notif_${++notificationIdCounter}_${Date.now()}`; - const newNotification: DegradationNotification = { - ...notification, - id, - showedAt: new Date().toISOString(), - }; - - set(state => ({ - notifications: [newNotification, ...state.notifications].slice(0, 50), // Keep last 50 - })); - - return id; - }, - - dismissNotification: (notificationId: string, action?: string) => { - set(state => ({ - notifications: state.notifications.map(n => - n.id === notificationId - ? { ...n, dismissedAt: new Date().toISOString(), actionTaken: action } - : n - ), - })); - }, - - clearNotifications: () => { - set({ notifications: [] }); - }, - - getUnreadNotifications: (): DegradationNotification[] => { - return get().notifications.filter(n => !n.dismissedAt); - }, - - // Preference actions - setShowDegradationBanners: (show: boolean) => { - set(state => ({ - preferences: { ...state.preferences, showDegradationBanners: show }, - })); - }, - setAutoDismissAlerts: (autoDismiss: boolean) => { - set(state => ({ - preferences: { ...state.preferences, autoDismissDegradationAlerts: autoDismiss }, - })); - }, - - setRemindPermissionRetry: (remind: boolean) => { - set(state => ({ - preferences: { ...state.preferences, remindPermissionRetry: remind }, - })); - }, - }), + set(state => ({ + notifications: [newNotification, ...state.notifications].slice(0, 50), // Keep last 50 + })); + + return id; + }, + + dismissNotification: (notificationId: string, action?: string) => { + set(state => ({ + notifications: state.notifications.map(n => + n.id === notificationId + ? { ...n, dismissedAt: new Date().toISOString(), actionTaken: action } + : n + ), + })); + }, + + clearNotifications: () => { + set({ notifications: [] }); + }, + + getUnreadNotifications: (): DegradationNotification[] => { + return get().notifications.filter(n => !n.dismissedAt); + }, + + // Preference actions + setShowDegradationBanners: (show: boolean) => { + set(state => ({ + preferences: { ...state.preferences, showDegradationBanners: show }, + })); + }, + + setAutoDismissAlerts: (autoDismiss: boolean) => { + set(state => ({ + preferences: { ...state.preferences, autoDismissDegradationAlerts: autoDismiss }, + })); + }, + + setRemindPermissionRetry: (remind: boolean) => { + set(state => ({ + preferences: { ...state.preferences, remindPermissionRetry: remind }, + })); + }, + }; + }, { 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 9d7b5891..6fd2ac8d 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 { toUnixMs } from './persistence'; +import { createHydrationErrorRecovery, secureStorageJSONStorage, toUnixMs } from './persistence'; import { sentryContextService } from '../services/sentryContext'; export interface User { @@ -21,6 +21,7 @@ interface AppState { refreshToken: string | null; sessionExpiresAt: number | null; sessionExpiringSoon: boolean; + theme: 'light' | 'dark'; isLoading: boolean; error: string | null; setUser: (user: User | null) => void; @@ -34,76 +35,91 @@ 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, - setUser: user => { - set({ user, isAuthenticated: !!user }, false, 'setUser'); - // Sync Sentry scope with the signed-in user so every subsequent - // error report is automatically tagged with user identity. - if (user) { - sentryContextService.setUser({ - id: user.id, - email: user.email, - username: user.name, - role: user.role, - }); - } else { + 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 + // error report is automatically tagged with user identity. + if (user) { + sentryContextService.setUser({ + id: user.id, + email: user.email, + username: user.name, + role: user.role, + }); + } else { + sentryContextService.clearUser(); + } + }, + setTheme: theme => set({ theme }, false, 'setTheme'), + setTokens: (accessToken, refreshToken, sessionExpiresAt) => + set( + { + accessToken, + refreshToken, + sessionExpiresAt: toUnixMs(sessionExpiresAt), + }, + false, + 'setTokens' + ), + setSessionExpiringSoon: sessionExpiringSoon => + set({ sessionExpiringSoon }, false, 'setSessionExpiringSoon'), + setAuthLoading: isAuthLoading => set({ isAuthLoading }, false, 'setAuthLoading'), + setAuthError: authError => set({ authError }, false, 'setAuthError'), + logout: () => { + set( + { + user: null, + isAuthenticated: false, + isAuthLoading: false, + authError: null, + accessToken: null, + refreshToken: null, + sessionExpiresAt: null, + sessionExpiringSoon: false, + }, + false, + 'logout' + ); + // Clear Sentry user scope and reset breadcrumb trail on logout sentryContextService.clearUser(); - } - }, - setTheme: theme => set({ theme }, false, 'setTheme'), - setTokens: (accessToken, refreshToken, sessionExpiresAt) => - set( - { - accessToken, - refreshToken, - sessionExpiresAt: toUnixMs(sessionExpiresAt), - }, - false, - 'setTokens' - ), - setSessionExpiringSoon: sessionExpiringSoon => - set({ sessionExpiringSoon }, false, 'setSessionExpiringSoon'), - setAuthLoading: isAuthLoading => set({ isAuthLoading }, false, 'setAuthLoading'), - setAuthError: authError => set({ authError }, false, 'setAuthError'), - logout: () => { - set( - { - user: null, - isAuthenticated: false, - isAuthLoading: false, - authError: null, - accessToken: null, - refreshToken: null, - sessionExpiresAt: null, - sessionExpiringSoon: false, - }, - false, - 'logout' - ); - // Clear Sentry user scope and reset breadcrumb trail on logout - sentryContextService.clearUser(); - sentryContextService.resetSession(); - }, - setLoading: isLoading => set({ isLoading }, false, 'setLoading'), - setError: error => set({ error }, false, 'setError'), - })), + sentryContextService.resetSession(); + }, + setLoading: isLoading => set({ isLoading }, false, 'setLoading'), + setError: error => set({ error }, false, 'setError'), + }; + }), { name: 'app-auth-storage', - storage: createJSONStorage(() => secureStorageAdapter), + 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 c3bc75a5..25629a62 100644 --- a/src/store/notificationStore.ts +++ b/src/store/notificationStore.ts @@ -1,14 +1,14 @@ -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, - NotificationHistoryEntry, - NotificationPreferences, - NotificationType, - StoredNotification, + DEFAULT_NOTIFICATION_PREFERENCES, + NotificationData, + NotificationHistoryEntry, + NotificationPreferences, + NotificationType, + StoredNotification, } from '../types/notifications'; interface NotificationState { @@ -59,250 +59,266 @@ interface NotificationState { isNotificationTypeEnabled: (type: NotificationType) => boolean; } +const createInitialNotificationState = () => ({ + pushToken: null, + isTokenRegistered: false, + tokenLastUpdated: null, + hasPromptedForPermission: false, + permissionDeniedAt: null, + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + notifications: [], + unreadCount: 0, + notificationHistory: [], + lastEngagedAt: null, + lastNotificationSentAtByType: {}, +}); + +let resetNotificationStoreAfterHydrationError = () => {}; + export const useNotificationStore = create()( persist( - (set, get) => ({ - // Initial state - pushToken: null, - isTokenRegistered: false, - tokenLastUpdated: null, - hasPromptedForPermission: false, - permissionDeniedAt: null, - preferences: DEFAULT_NOTIFICATION_PREFERENCES, - notifications: [], - unreadCount: 0, - notificationHistory: [], - lastEngagedAt: null, - lastNotificationSentAtByType: {}, - - // Push token actions - setPushToken: token => - set({ - pushToken: token, - tokenLastUpdated: token ? new Date().toISOString() : null, - }), - - setTokenRegistered: registered => set({ isTokenRegistered: registered }), - - clearPushToken: () => - set({ - pushToken: null, - isTokenRegistered: false, - tokenLastUpdated: null, - }), - - // Permission actions - setHasPromptedForPermission: prompted => set({ hasPromptedForPermission: prompted }), - - setPermissionDeniedAt: date => set({ permissionDeniedAt: date }), - - // Preference actions - setPreference: (key, value) => - set(state => ({ - preferences: { - ...state.preferences, - [key]: value, - }, - })), - - setAllPreferences: preferences => set({ preferences }), - - resetPreferences: () => set({ preferences: DEFAULT_NOTIFICATION_PREFERENCES }), - - // Notification actions - addNotification: notification => - set(state => { - const now = Date.now(); - const fingerprint = buildNotificationFingerprint(notification); - const dedupeWindowMinutes = 10; - const cutoff = Date.now() - dedupeWindowMinutes * 60 * 1000; - - const recentHistory = state.notificationHistory.filter( - entry => entry.receivedAt >= cutoff - ); - - const isDuplicate = recentHistory.some(entry => entry.fingerprint === fingerprint); - if (isDuplicate) { - return {}; - } + (set, get): NotificationState => { + resetNotificationStoreAfterHydrationError = () => set(createInitialNotificationState()); + + return { + // Initial state + ...createInitialNotificationState(), + + // Push token actions + setPushToken: token => + set({ + pushToken: token, + tokenLastUpdated: token ? new Date().toISOString() : null, + }), + + setTokenRegistered: registered => set({ isTokenRegistered: registered }), + + clearPushToken: () => + set({ + pushToken: null, + isTokenRegistered: false, + tokenLastUpdated: null, + }), + + // Permission actions + setHasPromptedForPermission: prompted => set({ hasPromptedForPermission: prompted }), + + setPermissionDeniedAt: date => set({ permissionDeniedAt: date }), + + // Preference actions + setPreference: (key, value) => + set(state => ({ + preferences: { + ...state.preferences, + [key]: value, + }, + })), + + setAllPreferences: preferences => set({ preferences }), + + resetPreferences: () => set({ preferences: DEFAULT_NOTIFICATION_PREFERENCES }), + + // Notification actions + addNotification: notification => + set(state => { + const now = Date.now(); + const fingerprint = buildNotificationFingerprint(notification); + const dedupeWindowMinutes = 10; + const cutoff = Date.now() - dedupeWindowMinutes * 60 * 1000; + + const recentHistory = state.notificationHistory.filter( + entry => entry.receivedAt >= cutoff + ); - const groupKey = buildNotificationGroupKey(notification.type, notification.data); - const existingIndex = state.notifications.findIndex( - item => buildNotificationGroupKey(item.type, item.data) === groupKey - ); - - let notifications: StoredNotification[]; - - if (existingIndex >= 0) { - const existing = state.notifications[existingIndex]; - const groupCount = (existing.groupCount ?? 1) + 1; - const title = formatGroupedTitle( - notification.type, - groupCount, - existing.title, - notification.title + const isDuplicate = recentHistory.some(entry => entry.fingerprint === fingerprint); + if (isDuplicate) { + return {}; + } + + const groupKey = buildNotificationGroupKey(notification.type, notification.data); + const existingIndex = state.notifications.findIndex( + item => buildNotificationGroupKey(item.type, item.data) === groupKey ); - const body = formatGroupedBody(existing.body, notification.body, groupCount); - - const updatedNotification: StoredNotification = { - ...existing, - title, - body, - groupCount, - read: false, - receivedAt: now, - }; - notifications = [ - updatedNotification, - ...state.notifications.filter((_, index) => index !== existingIndex), - ]; - } else { - const newNotification: StoredNotification = { - ...notification, - id: generateId(), - receivedAt: now, - read: false, - groupCount: 1, - }; + let notifications: StoredNotification[]; + + if (existingIndex >= 0) { + const existing = state.notifications[existingIndex]; + const groupCount = (existing.groupCount ?? 1) + 1; + const title = formatGroupedTitle( + notification.type, + groupCount, + existing.title, + notification.title + ); + const body = formatGroupedBody(existing.body, notification.body, groupCount); + + const updatedNotification: StoredNotification = { + ...existing, + title, + body, + groupCount, + read: false, + receivedAt: now, + }; + + notifications = [ + updatedNotification, + ...state.notifications.filter((_, index) => index !== existingIndex), + ]; + } else { + const newNotification: StoredNotification = { + ...notification, + id: generateId(), + receivedAt: now, + read: false, + groupCount: 1, + }; + + notifications = [newNotification, ...state.notifications].slice(0, 100); + } + + const notificationHistory = [{ fingerprint, receivedAt: now }, ...recentHistory].slice( + 0, + 200 + ); - notifications = [newNotification, ...state.notifications].slice(0, 100); + return { + notifications, + unreadCount: state.unreadCount + 1, + notificationHistory, + }; + }), + + markAsRead: notificationId => + set(state => { + const notification = state.notifications.find(n => n.id === notificationId); + if (!notification || notification.read) return state; + + return { + notifications: state.notifications.map(n => + n.id === notificationId ? { ...n, read: true } : n + ), + unreadCount: Math.max(0, state.unreadCount - 1), + }; + }), + + markAllAsRead: () => + set(state => ({ + notifications: state.notifications.map(n => ({ ...n, read: true })), + unreadCount: 0, + })), + + removeNotification: notificationId => + set(state => { + const notification = state.notifications.find(n => n.id === notificationId); + const wasUnread = notification && !notification.read; + + return { + notifications: state.notifications.filter(n => n.id !== notificationId), + unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount, + }; + }), + + clearAllNotifications: () => + set({ + notifications: [], + unreadCount: 0, + notificationHistory: [], + }), + + recordEngagement: () => + set({ + lastEngagedAt: new Date().toISOString(), + }), + + shouldThrottleNotification: (type, now = new Date()) => { + const state = get(); + const thresholdMinutes = state.getNotificationThrottleMinutes(now); + const lastSentAt = state.lastNotificationSentAtByType[type]; + + if (lastSentAt) { + const elapsedMinutes = (now.getTime() - lastSentAt) / (1000 * 60); + if (elapsedMinutes < thresholdMinutes) { + return true; + } } - const notificationHistory = [ - { fingerprint, receivedAt: now }, - ...recentHistory, - ].slice(0, 200); - - return { - notifications, - unreadCount: state.unreadCount + 1, - notificationHistory, - }; - }), - - markAsRead: notificationId => - set(state => { - const notification = state.notifications.find(n => n.id === notificationId); - if (!notification || notification.read) return state; - - return { - notifications: state.notifications.map(n => - n.id === notificationId ? { ...n, read: true } : n - ), - unreadCount: Math.max(0, state.unreadCount - 1), - }; - }), - - markAllAsRead: () => - set(state => ({ - notifications: state.notifications.map(n => ({ ...n, read: true })), - unreadCount: 0, - })), - - removeNotification: notificationId => - set(state => { - const notification = state.notifications.find(n => n.id === notificationId); - const wasUnread = notification && !notification.read; - - return { - notifications: state.notifications.filter(n => n.id !== notificationId), - unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount, - }; - }), - - clearAllNotifications: () => - set({ - notifications: [], - unreadCount: 0, - notificationHistory: [], - }), - - recordEngagement: () => - set({ - lastEngagedAt: new Date().toISOString(), - }), - - shouldThrottleNotification: (type, now = new Date()) => { - const state = get(); - const thresholdMinutes = state.getNotificationThrottleMinutes(now); - const lastSentAt = state.lastNotificationSentAtByType[type]; - - if (lastSentAt) { - const elapsedMinutes = (now.getTime() - lastSentAt) / (1000 * 60); - if (elapsedMinutes < thresholdMinutes) { - return true; + set({ + lastNotificationSentAtByType: { + ...state.lastNotificationSentAtByType, + [type]: now.getTime(), + }, + }); + return false; + }, + + getNotificationThrottleMinutes: (now = new Date()) => { + const { lastEngagedAt } = get(); + if (!lastEngagedAt) { + return 180; } - } - set({ - lastNotificationSentAtByType: { - ...state.lastNotificationSentAtByType, - [type]: now.getTime(), - }, - }); - return false; - }, + const inactiveHours = + (now.getTime() - new Date(lastEngagedAt).getTime()) / (1000 * 60 * 60); - getNotificationThrottleMinutes: (now = new Date()) => { - const { lastEngagedAt } = get(); - if (!lastEngagedAt) { + if (inactiveHours < 24) return 5; + if (inactiveHours < 72) return 30; return 180; - } - - const inactiveHours = - (now.getTime() - new Date(lastEngagedAt).getTime()) / (1000 * 60 * 60); - - if (inactiveHours < 24) return 5; - if (inactiveHours < 72) return 30; - return 180; - }, - - // Helpers - isNotificationTypeEnabled: type => { - const { preferences } = get(); - switch (type) { - case NotificationType.COURSE_UPDATE: - return preferences.courseUpdates; - case NotificationType.MESSAGE: - return preferences.messages; - case NotificationType.LEARNING_REMINDER: - return preferences.learningReminders; - case NotificationType.ACHIEVEMENT_UNLOCK: - return preferences.achievementUnlocks; - case NotificationType.COMMUNITY_ACTIVITY: - return preferences.communityActivity; - default: - return true; - } - }, - }), + }, + + // Helpers + isNotificationTypeEnabled: type => { + const { preferences } = get(); + switch (type) { + case NotificationType.COURSE_UPDATE: + return preferences.courseUpdates; + case NotificationType.MESSAGE: + return preferences.messages; + case NotificationType.LEARNING_REMINDER: + return preferences.learningReminders; + case NotificationType.ACHIEVEMENT_UNLOCK: + return preferences.achievementUnlocks; + case NotificationType.COMMUNITY_ACTIVITY: + return preferences.communityActivity; + default: + 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) => ({ ...n, - receivedAt: typeof n.receivedAt === 'string' ? new Date(n.receivedAt).getTime() : n.receivedAt, + receivedAt: + typeof n.receivedAt === 'string' ? new Date(n.receivedAt).getTime() : n.receivedAt, })); } // Convert history timestamps if (Array.isArray(state.notificationHistory)) { state.notificationHistory = state.notificationHistory.map((h: any) => ({ ...h, - receivedAt: typeof h.receivedAt === 'string' ? new Date(h.receivedAt).getTime() : h.receivedAt, + receivedAt: + typeof h.receivedAt === 'string' ? new Date(h.receivedAt).getTime() : h.receivedAt, })); } // Convert throttle timestamps if (state.lastNotificationSentAtByType) { const converted: Record = {}; Object.entries(state.lastNotificationSentAtByType).forEach(([k, v]) => { - converted[k] = typeof v === 'string' ? new Date(v as string).getTime() : (v as number); + converted[k] = + typeof v === 'string' ? new Date(v as string).getTime() : (v as number); }); state.lastNotificationSentAtByType = converted; } @@ -310,6 +326,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 970697bd..cdbb0ec4 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 2dbbb3f1..6c125360 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 cd1667ba..66c78f95 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,30 +60,6 @@ interface SettingsState { resetSettings: () => void; } -const DEFAULT_SETTINGS: Omit< - SettingsState, - keyof Omit< - SettingsState, - ProfileVisibility | DownloadQuality | StorageLimit | AppLanguage | FontSize | boolean - > -> = { - 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, @@ -101,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, @@ -152,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 9a34ab17..a5ade957 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 + ), } ) ); From 70915c5e2991a652215582c356c68df7c32ae7ad Mon Sep 17 00:00:00 2001 From: Jerry_tekh Date: Sat, 27 Jun 2026 23:33:37 +0100 Subject: [PATCH 2/2] Fix parser blockers after upstream merge --- src/components/ui/CachedImage.tsx | 2 -- src/services/formCache.ts | 34 +++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/ui/CachedImage.tsx b/src/components/ui/CachedImage.tsx index cb9ac9b4..2883839a 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 ac05452b..069ddb10 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;