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
176 changes: 176 additions & 0 deletions src/hooks/__tests__/useOfflineModeConnectivity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';

const syncData = vi.fn(async () => ({
success: true,
syncedItems: 0,
conflicts: [],
errors: [],
lastSyncTime: new Date().toISOString(),
}));

vi.mock('../../services/offlineSync', () => ({
OfflineStorage: class {
async init() {}
async clearAll() {}
},
OfflineSyncService: class {
syncData = syncData;
async getSyncStatus() {
return {
isSyncing: false,
pending: 0,
conflicted: 0,
resolved: 0,
deadLetter: 0,
lastSyncTime: null,
};
}
},
}));

vi.mock('../../store/synchronizationEngine', () => ({
syncEngine: { recordDrainResult: vi.fn(async () => undefined) },
}));

import { useOfflineMode } from '../useOfflineMode';

const goOffline = () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
window.dispatchEvent(new Event('offline'));
};

const goOnline = () => {
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
window.dispatchEvent(new Event('online'));
};

beforeEach(() => {
vi.useFakeTimers();
syncData.mockClear();
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
});

afterEach(() => {
vi.useRealTimers();
});

describe('useOfflineMode connectivity debouncing', () => {
it('starts from the browser online state', () => {
const { result } = renderHook(() => useOfflineMode({ connectivityDebounceMs: 100 }));

expect(result.current.isOnline).toBe(true);
});

it('reports going offline once the state holds', async () => {
const { result } = renderHook(() => useOfflineMode({ connectivityDebounceMs: 100 }));

await act(async () => {
goOffline();
await vi.advanceTimersByTimeAsync(100);
});

expect(result.current.isOnline).toBe(false);
});

// Each `online` event used to start a sync that the next `offline` cut
// short, so the queue never drained and every attempt burned a retry.
it('does not sync while connectivity is flapping', async () => {
renderHook(() => useOfflineMode({ connectivityDebounceMs: 500 }));

await act(async () => {
goOffline();
await vi.advanceTimersByTimeAsync(50);
goOnline();
await vi.advanceTimersByTimeAsync(50);
goOffline();
await vi.advanceTimersByTimeAsync(50);
goOnline();
await vi.advanceTimersByTimeAsync(50);
});

expect(syncData).not.toHaveBeenCalled();
});

it('syncs once when connectivity settles online', async () => {
const { result } = renderHook(() => useOfflineMode({ connectivityDebounceMs: 100 }));

await act(async () => {
await result.current.initializeOfflineMode();
});

await act(async () => {
goOffline();
await vi.advanceTimersByTimeAsync(100);
});

await act(async () => {
goOnline();
await vi.advanceTimersByTimeAsync(100);
});

expect(syncData).toHaveBeenCalledTimes(1);
expect(result.current.isOnline).toBe(true);
});

it('does not sync when the settled state is offline', async () => {
renderHook(() => useOfflineMode({ connectivityDebounceMs: 100 }));

await act(async () => {
goOffline();
await vi.advanceTimersByTimeAsync(100);
});

expect(syncData).not.toHaveBeenCalled();
});

it('flushes the pending state on demand', async () => {
const { result } = renderHook(() => useOfflineMode({ connectivityDebounceMs: 10_000 }));

await act(async () => {
goOffline();
});

expect(result.current.isOnline).toBe(true);

await act(async () => {
result.current.flushConnectivity();
});

expect(result.current.isOnline).toBe(false);
});

// A timer firing after unmount would sync against a torn-down service.
it('cancels a pending transition on unmount', async () => {
const { unmount } = renderHook(() => useOfflineMode({ connectivityDebounceMs: 500 }));

await act(async () => {
goOffline();
await vi.advanceTimersByTimeAsync(50);
goOnline();
});

unmount();

await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});

expect(syncData).not.toHaveBeenCalled();
});

it('stops listening after unmount', async () => {
const { unmount } = renderHook(() => useOfflineMode({ connectivityDebounceMs: 100 }));

unmount();

await act(async () => {
goOffline();
await vi.advanceTimersByTimeAsync(1_000);
goOnline();
await vi.advanceTimersByTimeAsync(1_000);
});

expect(syncData).not.toHaveBeenCalled();
});
});
70 changes: 68 additions & 2 deletions src/hooks/useOfflineMode.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
'use client';

import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CONNECTIVITY_DEBOUNCE_MS,
createConnectivityDebouncer,
type ConnectivityDebouncer,
} from '../utils/pwaUtils';
import {
OfflineStorage,
OfflineSyncService,
Expand Down Expand Up @@ -41,11 +46,21 @@ const estimateCourseSize = (course: DownloadCourseInput) => {
return (course.sizeBytes || 0) + moduleEstimate + assetEstimate;
};

export const useOfflineMode = () => {
export interface OfflineModeOptions {
/** Settle time for connectivity changes. Defaults to CONNECTIVITY_DEBOUNCE_MS. */
connectivityDebounceMs?: number;
}

export const useOfflineMode = (options: OfflineModeOptions = {}) => {
const { connectivityDebounceMs = CONNECTIVITY_DEBOUNCE_MS } = options;
const [isInitialized, setIsInitialized] = useState(false);
const [isOnline, setIsOnline] = useState<boolean>(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
);

const storageRef = useRef<OfflineStorage | null>(null);
const syncRef = useRef<OfflineSyncService | null>(null);
const debouncerRef = useRef<ConnectivityDebouncer | null>(null);

const initializeOfflineMode = useCallback(async () => {
if (storageRef.current && syncRef.current) {
Expand Down Expand Up @@ -285,9 +300,58 @@ export const useOfflineMode = () => {
return URL.createObjectURL(asset.data);
}, []);

// Held in a ref so the listeners below are attached once, rather than being
// torn down and re-subscribed every time syncData's identity changes.
const syncDataRef = useRef(syncData);
syncDataRef.current = syncData;

/**
* Reacts to connectivity only once it has held for the debounce window.
*
* A flapping connection fires `online`/`offline` several times a second, and
* each `online` used to start a sync that the next `offline` interrupted —
* so the queue never drained and every partial attempt burned a retry.
*/
useEffect(() => {
if (typeof window === 'undefined') return;

const debouncer = createConnectivityDebouncer(
navigator.onLine,
(online) => {
setIsOnline(online);
if (online) void syncDataRef.current().catch(() => undefined);
},
{ debounceMs: connectivityDebounceMs },
);

debouncerRef.current = debouncer;

const handleOnline = () => debouncer.push(true);
const handleOffline = () => debouncer.push(false);

window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);

return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
// A pending timer firing after unmount would sync against a torn-down
// service.
debouncer.cancel();
debouncerRef.current = null;
};
}, [connectivityDebounceMs]);

/** Applies the pending connectivity state immediately, skipping the wait. */
const flushConnectivity = useCallback(() => {
debouncerRef.current?.flush();
}, []);

return useMemo(
() => ({
isInitialized,
isOnline,
flushConnectivity,
initializeOfflineMode,
cleanupOfflineMode,
downloadCourse,
Expand All @@ -310,6 +374,8 @@ export const useOfflineMode = () => {
}),
[
isInitialized,
isOnline,
flushConnectivity,
initializeOfflineMode,
cleanupOfflineMode,
downloadCourse,
Expand Down
Loading
Loading