feat: implement offline synchronization with mutation queue and background sync

- 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
This commit is contained in:
2025-12-01 20:34:21 +00:00
parent ef6a0bf5af
commit 9726f3a9e1
8 changed files with 716 additions and 1 deletions

298
hooks/useOfflineSync.ts Normal file
View File

@@ -0,0 +1,298 @@
'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,
}
}