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
+ ),
}
)
);