Skip to content
Merged
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
132 changes: 132 additions & 0 deletions src/__tests__/issues/fixes_620_621_622.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Unit tests for issues #620, #621, #622
*/

// ─── Issue #621: CachedImage Animated.Value via useRef ──────────────────────

jest.mock('expo-image', () => ({
Image: 'Image',
}));
jest.mock('../../services/imagePerformance', () => ({
imagePerformanceService: { recordImageLoad: jest.fn() },
}));
jest.mock('../../store/settingsStore', () => ({
useSettingsStore: (sel: any) => sel({ dataSaverEnabled: false }),
}));
jest.mock('../../utils/imageCache', () => ({
ImageCache: { prefetchImages: jest.fn().mockResolvedValue(undefined) },
}));
jest.mock('../../utils/imageOptimization', () => ({
buildOptimizedImageSources: jest.fn(uri => ({
primaryUri: uri,
fallbackUri: uri,
lqipUri: uri,
dpr: 1,
})),
}));
jest.mock('../../utils/logger', () => ({
logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn(), info: jest.fn() },
}));

import React from 'react';

Check warning on line 31 in src/__tests__/issues/fixes_620_621_622.test.ts

View workflow job for this annotation

GitHub Actions / ci

Import in body of module; reorder to top
import { renderHook } from '@testing-library/react-hooks';

Check warning on line 32 in src/__tests__/issues/fixes_620_621_622.test.ts

View workflow job for this annotation

GitHub Actions / ci

Import in body of module; reorder to top
import { Animated } from 'react-native';

describe('#621 CachedImage — Animated.Value reference stability', () => {
it('opacity ref is identical across multiple renders (Object.is)', () => {
// Simulate what CachedImageComponent does: useRef(new Animated.Value(0)).current
const { result, rerender } = renderHook(() => {
const opacity = React.useRef(new Animated.Value(0)).current;
return opacity;
});

const first = result.current;

for (let i = 0; i < 4; i++) {
rerender();
}

expect(Object.is(result.current, first)).toBe(true);
});
});

// ─── Issue #620: invalidateByPattern ────────────────────────────────────────

jest.mock('@react-native-async-storage/async-storage', () => ({
__esModule: true,
default: {
setItem: jest.fn().mockResolvedValue(undefined),
getItem: jest.fn().mockResolvedValue(null),
removeItem: jest.fn().mockResolvedValue(undefined),
getAllKeys: jest.fn().mockResolvedValue([]),
},
}));
jest.mock('../../services/mobileAnalytics', () => ({
mobileAnalyticsService: { trackEvent: jest.fn() },
}));
jest.mock('../../utils/trackingEvents', () => ({
AnalyticsEvent: { PERFORMANCE_METRIC: 'performance_metric' },
}));

import { setCache, getCache, invalidateByPattern, clearCache } from '../../services/api/cache';

describe('#620 invalidateByPattern', () => {
beforeEach(() => clearCache());

it('removes matching cache entries and returns count', () => {
setCache('/api/courses', [1, 2], 60_000, 300_000);
setCache('/api/courses/123', { id: 123 }, 60_000, 300_000);
setCache('/api/users/1', { id: 1 }, 60_000, 300_000);

const removed = invalidateByPattern(/\/api\/courses/);

expect(removed).toBe(2);
expect(getCache('/api/courses')).toBeNull();
expect(getCache('/api/courses/123')).toBeNull();
// unrelated entry untouched
expect(getCache('/api/users/1')).not.toBeNull();
});

it('cache miss immediately after POST mutation via pattern', () => {
setCache('/api/courses', [1, 2], 60_000, 300_000);

// Simulate what the interceptor does after POST /api/courses
invalidateByPattern(/\/api\/courses/);

expect(getCache('/api/courses')).toBeNull();
});

it('returns 0 when no keys match', () => {
setCache('/api/users/1', { id: 1 }, 60_000, 300_000);
expect(invalidateByPattern(/\/api\/courses/)).toBe(0);
});
});

// ─── Issue #622: backgroundTaskScheduler 25-second timeout ──────────────────

import { BackgroundTaskScheduler } from '../../utils/backgroundTaskScheduler';

describe('#622 BackgroundTaskScheduler — 25-second timeout', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it('resolves with timedOut=true exactly at 25000ms', async () => {
const scheduler = new BackgroundTaskScheduler();
const neverResolves = () => new Promise<void>(() => {/* never */});

const promise = scheduler.runWithTimeout(neverResolves, 'testTask', 25_000);
jest.advanceTimersByTime(25_000);

const result = await promise;
expect(result.timedOut).toBe(true);
expect(result.taskDurationMs).toBeGreaterThanOrEqual(25_000);
});

it('resolves with timedOut=false when task completes before timeout', async () => {
const scheduler = new BackgroundTaskScheduler();
const fastTask = async () => { /* instant */ };

const result = await scheduler.runWithTimeout(fastTask, 'fastTask', 25_000);
expect(result.timedOut).toBe(false);
});
});
51 changes: 32 additions & 19 deletions src/components/ui/CachedImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image';
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
ImageStyle,
PixelRatio,
StyleProp,
Expand Down Expand Up @@ -128,6 +129,8 @@ const CachedImageComponent: React.FC<CachedImageProps> = ({
const dataSaverEnabled = useSettingsStore(state => state.dataSaverEnabled);
const startedAtRef = useRef<number | null>(null);
const usingFallbackRef = useRef(false);
// Stable across re-renders — initialized exactly once per component instance
const opacity = useRef(new Animated.Value(0)).current;

const styleWidth = resolveStyleDimension(style as StyleProp<ImageStyle>, 'width');
const styleHeight = resolveStyleDimension(style as StyleProp<ImageStyle>, 'height');
Expand Down Expand Up @@ -186,6 +189,12 @@ const CachedImageComponent: React.FC<CachedImageProps> = ({
setIsLoading(false);
setError(null);

Animated.timing(opacity, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}).start();

const startedAt = startedAtRef.current;
if (startedAt) {
imagePerformanceService.recordImageLoad({
Expand Down Expand Up @@ -233,25 +242,29 @@ const CachedImageComponent: React.FC<CachedImageProps> = ({

return (
<View style={getContainerStyle()}>
<ExpoImage
source={[{ uri: optimizedSources.primaryUri }, { uri: optimizedSources.fallbackUri }]}
placeholder={{ uri: optimizedSources.lqipUri }}
transition={250}
onLoadStart={() => {
startedAtRef.current = Date.now();
usingFallbackRef.current = false;
}}
onLoadingComplete={handleLoadingComplete}
onError={handleError}
accessibilityLabel={alt}
accessibilityRole="image"
{...expoImageProps}
style={[
styles.image,
aspectRatioStyle ? { aspectRatio: detectedDimensions?.aspectRatio } : null,
style,
]}
/>
<Animated.View style={[StyleSheet.absoluteFill, { opacity }]}>
<ExpoImage
source={[{ uri: optimizedSources.primaryUri }, { uri: optimizedSources.fallbackUri }]}
placeholder={{ uri: optimizedSources.lqipUri }}
transition={0}
onLoadStart={() => {
startedAtRef.current = Date.now();
usingFallbackRef.current = false;
}}
onLoadingComplete={handleLoadingComplete}
onError={handleError}
accessibilityLabel={alt}
accessibilityRole="image"
{...expoImageProps}
style={[
styles.image,
aspectRatioStyle ? { aspectRatio: detectedDimensions?.aspectRatio } : null,
style,
]}
/>
</Animated.View>

</Animated.View>

{/* Loading indicator overlay */}
{isLoading && showLoadingIndicator && (
Expand Down
40 changes: 40 additions & 0 deletions src/config/apiCacheConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Defines which cache keys to invalidate after a successful mutation.
* Keys are matched against the request URL using the provided RegExp patterns.
*/
export const MUTATION_INVALIDATION_MAP: Array<{
urlPattern: RegExp;
methods: string[];
invalidatePatterns: RegExp[];
}> = [
{
urlPattern: /\/api\/courses\/[^/]+$/,
methods: ['PUT', 'PATCH', 'DELETE'],
invalidatePatterns: [/\/api\/courses/],
},
{
urlPattern: /\/api\/courses$/,
methods: ['POST'],
invalidatePatterns: [/\/api\/courses/],
},
{
urlPattern: /\/api\/users\/[^/]+$/,
methods: ['PUT', 'PATCH', 'DELETE'],
invalidatePatterns: [/\/api\/users/],
},
{
urlPattern: /\/api\/users$/,
methods: ['POST'],
invalidatePatterns: [/\/api\/users/],
},
{
urlPattern: /\/api\/lessons\/[^/]+$/,
methods: ['PUT', 'PATCH', 'DELETE'],
invalidatePatterns: [/\/api\/lessons/],
},
{
urlPattern: /\/api\/lessons$/,
methods: ['POST'],
invalidatePatterns: [/\/api\/lessons/],
},
];
13 changes: 12 additions & 1 deletion src/services/api/axios.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@

import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';

import { invalidateCacheForBatchRequests, invalidateCacheForMutation } from './cache';
import { invalidateCacheForBatchRequests, invalidateCacheForMutation, invalidateByPattern } from './cache';
import { requestQueue } from './requestQueue';
import { getEnv } from '../../config';
import { MUTATION_INVALIDATION_MAP } from '../../config/apiCacheConfig';
import { appLogger } from '../../utils/logger';
import { startTiming, notifyEntry } from '../../utils/performanceTiming';
import { healthMetricsService } from '../healthMetrics';
Expand Down Expand Up @@ -55,6 +56,16 @@ function invalidateSuccessfulMutationCache(config: InternalAxiosRequestConfig):
return;
}

// Pattern-based invalidation from config map
for (const rule of MUTATION_INVALIDATION_MAP) {
if (rule.methods.includes(method) && rule.urlPattern.test(url)) {
for (const pattern of rule.invalidatePatterns) {
invalidateByPattern(pattern);
}
return;
}
}

invalidateCacheForMutation(method, url);
}

Expand Down
18 changes: 18 additions & 0 deletions src/services/api/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,24 @@ export function invalidateCache(key: string): void {
void removePersistentCache(key);
}

export function invalidateByPattern(pattern: RegExp): number {
let removed = 0;

for (const key of Array.from(store.keys())) {
if (pattern.test(key) && removeMemoryEntry(key)) {
removed++;
}
}

if (removed > 0) {
invalidations += removed;
maybeReportCacheStats('invalidate:pattern');
}

void invalidatePersistentWhere(key => pattern.test(key));
return removed;
}

export function invalidateCacheByPrefix(prefix: string): number {
let removed = 0;

Expand Down
22 changes: 22 additions & 0 deletions src/services/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useDeviceStore } from '../store/deviceStore';
import { useSettingsStore } from '../store/settingsStore';
import { useSyncStore } from '../store/syncStore';
import { logger } from '../utils/logger';
import { backgroundScheduler } from '../utils/backgroundTaskScheduler';

import type {
ConflictResolutionStrategy as VersionedConflictResolutionStrategy,
Expand Down Expand Up @@ -208,6 +209,27 @@ export class SyncService {
return Math.min(this.getBaseSyncInterval() * 2 ** failures, MAX_AUTO_SYNC_BACKOFF_MS);
}

/**
* Run sync as a bounded background task (25-second limit for iOS).
* Checkpoints progress on timeout so partial work is not lost.
*/
async runBackgroundSync(): Promise<void> {
const result = await backgroundScheduler.runWithTimeout(
async () => {
await this.syncPendingOperations(false);
},
'syncService.runBackgroundSync'
);

if (result.timedOut) {
// Checkpoint: persist current isSyncing=false so next launch can resume
this.isSyncing = false;
logger.warn('SyncService: Background sync timed out — checkpointed for next run', {
taskDurationMs: result.taskDurationMs,
});
}
}

/**
* Manual sync trigger
*/
Expand Down
Loading
Loading