- Created useOfflineSync hook for connectivity detection and sync management - Implemented localStorage-based mutation queue for offline operations - Added background sync support in service worker - Created OfflineIndicator component to show connection status - Added offline-aware action wrappers for seamless offline/online transitions - Implemented conflict handling with retry logic (up to 3 retries) - Auto-sync when connection is restored - Validates Requirements 4.5
299 lines
8.2 KiB
TypeScript
299 lines
8.2 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
|
|
/**
|
|
* Represents a queued mutation to be synced when online
|
|
*/
|
|
export interface QueuedMutation {
|
|
id: string
|
|
type: 'create' | 'update' | 'delete'
|
|
entity: 'list' | 'item'
|
|
data: any
|
|
timestamp: number
|
|
retries: number
|
|
}
|
|
|
|
/**
|
|
* Hook for managing offline synchronization
|
|
* Detects connectivity, queues mutations, and syncs when online
|
|
* Validates: Requirements 4.5
|
|
*/
|
|
export function useOfflineSync() {
|
|
const [isOnline, setIsOnline] = useState(true)
|
|
const [isSyncing, setIsSyncing] = useState(false)
|
|
const [syncError, setSyncError] = useState<Error | null>(null)
|
|
const [queueLength, setQueueLength] = useState(0)
|
|
const syncInProgressRef = useRef(false)
|
|
|
|
// Initialize online status
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
setIsOnline(navigator.onLine)
|
|
}
|
|
}, [])
|
|
|
|
// Load queue length from localStorage
|
|
const loadQueueLength = useCallback(() => {
|
|
if (typeof window === 'undefined') return 0
|
|
|
|
try {
|
|
const queue = localStorage.getItem('mutation-queue')
|
|
if (queue) {
|
|
const parsed = JSON.parse(queue) as QueuedMutation[]
|
|
return parsed.length
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load queue length:', error)
|
|
}
|
|
return 0
|
|
}, [])
|
|
|
|
// Update queue length on mount and when syncing
|
|
useEffect(() => {
|
|
setQueueLength(loadQueueLength())
|
|
|
|
// Listen for mutation-queued events
|
|
const handleMutationQueued = () => {
|
|
setQueueLength(loadQueueLength())
|
|
}
|
|
|
|
window.addEventListener('mutation-queued', handleMutationQueued)
|
|
|
|
return () => {
|
|
window.removeEventListener('mutation-queued', handleMutationQueued)
|
|
}
|
|
}, [loadQueueLength, isSyncing])
|
|
|
|
/**
|
|
* Add a mutation to the queue
|
|
*/
|
|
const queueMutation = useCallback((mutation: Omit<QueuedMutation, 'id' | 'timestamp' | 'retries'>) => {
|
|
if (typeof window === 'undefined') return
|
|
|
|
try {
|
|
const queue = localStorage.getItem('mutation-queue')
|
|
const mutations: QueuedMutation[] = queue ? JSON.parse(queue) : []
|
|
|
|
const newMutation: QueuedMutation = {
|
|
...mutation,
|
|
id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
|
timestamp: Date.now(),
|
|
retries: 0,
|
|
}
|
|
|
|
mutations.push(newMutation)
|
|
localStorage.setItem('mutation-queue', JSON.stringify(mutations))
|
|
setQueueLength(mutations.length)
|
|
|
|
console.log('Mutation queued:', newMutation)
|
|
} catch (error) {
|
|
console.error('Failed to queue mutation:', error)
|
|
}
|
|
}, [])
|
|
|
|
/**
|
|
* Process the mutation queue and sync with server
|
|
*/
|
|
const syncQueue = useCallback(async () => {
|
|
if (typeof window === 'undefined') return
|
|
if (!isOnline) return
|
|
if (syncInProgressRef.current) return
|
|
|
|
syncInProgressRef.current = true
|
|
setIsSyncing(true)
|
|
setSyncError(null)
|
|
|
|
try {
|
|
const queue = localStorage.getItem('mutation-queue')
|
|
if (!queue) {
|
|
setIsSyncing(false)
|
|
syncInProgressRef.current = false
|
|
return
|
|
}
|
|
|
|
const mutations: QueuedMutation[] = JSON.parse(queue)
|
|
if (mutations.length === 0) {
|
|
setIsSyncing(false)
|
|
syncInProgressRef.current = false
|
|
return
|
|
}
|
|
|
|
console.log(`Syncing ${mutations.length} mutations...`)
|
|
|
|
const failedMutations: QueuedMutation[] = []
|
|
|
|
// Process mutations sequentially to maintain order
|
|
for (const mutation of mutations) {
|
|
try {
|
|
await processMutation(mutation)
|
|
console.log('Mutation synced:', mutation.id)
|
|
} catch (error) {
|
|
console.error('Failed to sync mutation:', mutation.id, error)
|
|
|
|
// Retry logic: keep in queue if retries < 3
|
|
if (mutation.retries < 3) {
|
|
failedMutations.push({
|
|
...mutation,
|
|
retries: mutation.retries + 1,
|
|
})
|
|
} else {
|
|
console.error('Mutation failed after 3 retries, discarding:', mutation.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update queue with failed mutations
|
|
if (failedMutations.length > 0) {
|
|
localStorage.setItem('mutation-queue', JSON.stringify(failedMutations))
|
|
setQueueLength(failedMutations.length)
|
|
setSyncError(new Error(`${failedMutations.length} mutations failed to sync`))
|
|
} else {
|
|
localStorage.removeItem('mutation-queue')
|
|
setQueueLength(0)
|
|
}
|
|
|
|
console.log('Sync complete')
|
|
} catch (error) {
|
|
console.error('Sync failed:', error)
|
|
setSyncError(error instanceof Error ? error : new Error('Sync failed'))
|
|
} finally {
|
|
setIsSyncing(false)
|
|
syncInProgressRef.current = false
|
|
}
|
|
}, [isOnline])
|
|
|
|
/**
|
|
* Process a single mutation by calling the appropriate server action
|
|
* Handles conflicts gracefully by catching errors
|
|
*/
|
|
const processMutation = async (mutation: QueuedMutation): Promise<void> => {
|
|
const { type, entity, data } = mutation
|
|
|
|
// Import actions dynamically to avoid circular dependencies
|
|
const actions = await import('@/lib/actions')
|
|
|
|
try {
|
|
if (entity === 'list') {
|
|
if (type === 'create') {
|
|
await actions.createList(data.title, data.color)
|
|
} else if (type === 'update') {
|
|
await actions.updateList(data.id, data)
|
|
} else if (type === 'delete') {
|
|
await actions.deleteList(data.id)
|
|
}
|
|
} else if (entity === 'item') {
|
|
if (type === 'create') {
|
|
await actions.createItem(
|
|
data.listId,
|
|
data.title,
|
|
data.description,
|
|
data.metadata
|
|
)
|
|
} else if (type === 'update') {
|
|
await actions.updateItem(data.id, data)
|
|
} else if (type === 'delete') {
|
|
await actions.deleteItem(data.id)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Handle conflicts gracefully
|
|
// If the resource doesn't exist (404), we can skip this mutation
|
|
if (error instanceof Error && error.message.includes('not found')) {
|
|
console.warn('Resource not found, skipping mutation:', mutation.id)
|
|
return // Don't throw, just skip this mutation
|
|
}
|
|
|
|
// For other errors, re-throw to trigger retry logic
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manually trigger sync
|
|
*/
|
|
const triggerSync = useCallback(() => {
|
|
if (isOnline) {
|
|
syncQueue()
|
|
}
|
|
}, [isOnline, syncQueue])
|
|
|
|
/**
|
|
* Auto-sync when coming back online
|
|
*/
|
|
useEffect(() => {
|
|
if (isOnline && queueLength > 0) {
|
|
// Small delay to ensure connection is stable
|
|
const timer = setTimeout(() => {
|
|
syncQueue()
|
|
}, 1000)
|
|
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [isOnline, queueLength, syncQueue])
|
|
|
|
/**
|
|
* Listen for online/offline events and service worker messages
|
|
*/
|
|
useEffect(() => {
|
|
const handleOnline = () => {
|
|
setIsOnline(true)
|
|
setSyncError(null)
|
|
|
|
// Register background sync with service worker
|
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
|
navigator.serviceWorker.controller.postMessage({
|
|
type: 'REGISTER_SYNC',
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleOffline = () => {
|
|
setIsOnline(false)
|
|
}
|
|
|
|
window.addEventListener('online', handleOnline)
|
|
window.addEventListener('offline', handleOffline)
|
|
|
|
// Listen for sync messages from service worker
|
|
const handleMessage = (event: MessageEvent) => {
|
|
if (event.data && event.data.type === 'SYNC_MUTATIONS') {
|
|
syncQueue()
|
|
}
|
|
}
|
|
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.addEventListener('message', handleMessage)
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline)
|
|
window.removeEventListener('offline', handleOffline)
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.removeEventListener('message', handleMessage)
|
|
}
|
|
}
|
|
}, [syncQueue])
|
|
|
|
/**
|
|
* Clear the queue (for testing or manual intervention)
|
|
*/
|
|
const clearQueue = useCallback(() => {
|
|
if (typeof window === 'undefined') return
|
|
|
|
localStorage.removeItem('mutation-queue')
|
|
setQueueLength(0)
|
|
setSyncError(null)
|
|
}, [])
|
|
|
|
return {
|
|
isOnline,
|
|
isSyncing,
|
|
syncError,
|
|
queueLength,
|
|
queueMutation,
|
|
triggerSync,
|
|
clearQueue,
|
|
}
|
|
}
|