Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
apiClient,
getCacheStatus,
getRevalidatingCacheKeys,
subscribeToCacheStatus,

Check failure on line 28 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Module '"./src/services/api"' has no exported member 'subscribeToCacheStatus'. Did you mean to use 'import subscribeToCacheStatus from "./src/services/api"' instead?
} from './src/services/api';
import { warmCriticalCaches } from './src/services/cacheWarming';
import { crashReportingService } from './src/services/crashReporting';
Expand All @@ -37,15 +37,19 @@
registerTokenWithBackend,
removeNotificationListener,
} from './src/services/pushNotifications';
import { requestQueue } from './src/services/requestQueue';

Check failure on line 40 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find module './src/services/requestQueue' or its corresponding type declarations.
import { searchIndexService } from './src/services/searchIndex';
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';

Check failure on line 52 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find module './src/utils/env' or its corresponding type declarations.
import { appLogger } from './src/utils/logger';

// Keep the splash screen visible while we fetch resources
Expand Down Expand Up @@ -119,6 +123,29 @@
);
};

const PreferencesResetToast = () => (
<View
style={{
position: 'absolute',
left: 16,
right: 16,
bottom: 32,
zIndex: 10000,
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: '#111827',
alignItems: 'center',
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 6,
}}
>
<Text style={{ color: '#f9fafb', fontWeight: '600' }}>Your preferences were reset</Text>
</View>
);

let _compromisedAlertShown = false;

function showCompromisedAlert(): void {
Expand All @@ -140,6 +167,32 @@

const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const [appIsReady, setAppIsReady] = React.useState(false);
const [showPreferencesResetToast, setShowPreferencesResetToast] = useState(false);

useEffect(() => {
let hideTimer: ReturnType<typeof setTimeout> | 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() {
Expand Down Expand Up @@ -329,8 +382,8 @@
return () => {
socketService.disconnect();
syncService.stopAutoSync();
notificationCleanup();

Check failure on line 385 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'notificationCleanup'.
removeNotificationListener(subscription);

Check failure on line 386 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'subscription'.
// @ts-ignore
global.onunhandledrejection = undefined;
};
Expand Down Expand Up @@ -419,6 +472,7 @@
<CacheRevalidationBanner />
<AppNavigator />
<NotificationPermissionExplanationSheet />
{showPreferencesResetToast ? <PreferencesResetToast /> : null}
</AuthProvider>
</ErrorBoundary>
);
Expand Down
123 changes: 123 additions & 0 deletions src/__tests__/store/hydrationRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -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' }),
})
);
});
});
2 changes: 0 additions & 2 deletions src/components/ui/CachedImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,6 @@ const CachedImageComponent: React.FC<CachedImageProps> = ({
/>
</Animated.View>

</Animated.View>

{/* Loading indicator overlay */}
{isLoading && showLoadingIndicator && (
<View style={styles.loadingOverlay}>
Expand Down
34 changes: 26 additions & 8 deletions src/services/formCache.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -54,7 +53,7 @@ export function pruneExpiredCache(store: FormCacheStore, now = Date.now()): Form
return pruned;
}

export async function loadFormCache(storageKey: string): Promise<FormCacheStore> {
export async function loadFormCache(storageKey = FORM_CACHE_STORAGE_KEY): Promise<FormCacheStore> {
const raw = await AsyncStorage.getItem(storageKey);
if (!raw) return {};
try {
Expand All @@ -69,10 +68,18 @@ export async function loadFormCache(storageKey: string): Promise<FormCacheStore>
}
}

export async function saveFormCache(storageKey: string, store: FormCacheStore): Promise<void> {
await AsyncStorage.setItem(storageKey, JSON.stringify(store));
export async function saveFormCache(store: FormCacheStore): Promise<void> {
await safeStorageWrite(FORM_CACHE_STORAGE_KEY, JSON.stringify(store));
export async function saveFormCache(storageKey: string, store: FormCacheStore): Promise<void>;
export async function saveFormCache(store: FormCacheStore): Promise<void>;
export async function saveFormCache(
storageKeyOrStore: string | FormCacheStore,
maybeStore?: FormCacheStore
): Promise<void> {
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(
Expand Down Expand Up @@ -104,7 +111,18 @@ export async function setCachedFieldValue(
storageKey: string,
key: FormCacheFieldKey,
value: string
): Promise<void>;
export async function setCachedFieldValue(key: FormCacheFieldKey, value: string): Promise<void>;
export async function setCachedFieldValue(
storageKeyOrKey: string,
keyOrValue: FormCacheFieldKey | string,
maybeValue?: string
): Promise<void> {
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;

Expand Down
35 changes: 27 additions & 8 deletions src/store/achievementStore.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down Expand Up @@ -263,13 +268,22 @@ function normalizeAchievementState(rawState: unknown): {
};
}

const createInitialAchievementState = () => ({
achievements: buildAchievementsFromProgress({}),
achievementProgress: {},
unlockedCount: 0,
isLoaded: false,
});

let resetAchievementStoreAfterHydrationError = () => {};

export const useAchievementStore = create<AchievementState>()(
persist(
(set, get) => ({
achievements: buildAchievementsFromProgress({}),
achievementProgress: {},
unlockedCount: 0,
isLoaded: false,
(set, get): AchievementState => {
resetAchievementStoreAfterHydrationError = () => set(createInitialAchievementState());

return {
...createInitialAchievementState(),

loadAchievements: () => {
const { isLoaded, achievements } = get();
Expand Down Expand Up @@ -373,11 +387,16 @@ export const useAchievementStore = create<AchievementState>()(
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,
Expand Down
Loading
Loading