add network listener management and enhance sync on reconnect functionality

This commit is contained in:
Mo Tarbin
2026-05-16 19:33:07 -04:00
parent 07c31ebde1
commit d618a09f6d
8 changed files with 92 additions and 36 deletions

View File

@@ -57,6 +57,11 @@ class NetworkManager {
registerNetworkListener(callback) {
this.connectionStatusListeners.push(callback)
}
unregisterNetworkListener(callback) {
this.connectionStatusListeners = this.connectionStatusListeners.filter(
cb => cb !== callback,
)
}
registerBackendSyncListener(callback) {
// if callback is not in the list already, add it
if (!this.queueSyncListeners.includes(callback)) {

View File

@@ -1,3 +1,5 @@
import { App as capacitorApp } from '@capacitor/app'
import { Capacitor } from '@capacitor/core'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import { commandQueue } from '../utils/CommandQueue'
@@ -14,6 +16,18 @@ export function useSyncOnReconnect() {
const initialized = useRef(false)
useEffect(() => {
let pendingPollInterval
let cacheRefreshInterval
let resumeListener
let networkListener
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
runSync()
}
}
const handleOnline = () => runSync()
const init = async () => {
if (initialized.current) return
initialized.current = true
@@ -23,24 +37,36 @@ export function useSyncOnReconnect() {
}
// 1. Device network change (works on native + real network drops)
networkManager.registerNetworkListener(async isOnline => {
networkListener = async isOnline => {
if (isOnline) {
await runSync()
}
})
}
networkManager.registerNetworkListener(networkListener)
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
runSync()
}
})
document.addEventListener('visibilitychange', handleVisibilityChange)
// 3. Browser online event (fires when device network is restored)
window.addEventListener('online', () => runSync())
window.addEventListener('online', handleOnline)
// 3.5 Native app resume (fires when returning to the foreground)
if (Capacitor.isNativePlatform()) {
resumeListener = await capacitorApp.addListener(
'appStateChange',
({ isActive }) => {
if (isActive) {
console.log(
'App resumed, checking connectivity and syncing if online...',
)
runSync()
}
},
)
}
// 4. Retry pending commands every 30s (catches backend restart)
setInterval(async () => {
pendingPollInterval = setInterval(async () => {
const pending = await commandQueue.getPending()
if (pending.length > 0) {
runSync()
@@ -48,7 +74,7 @@ export function useSyncOnReconnect() {
}, PENDING_POLL_MS)
// 5. Keep IDB cache fresh every 5 min while online (so offline reads are current)
setInterval(() => {
cacheRefreshInterval = setInterval(() => {
runSync()
}, CACHE_REFRESH_MS)
}
@@ -62,5 +88,22 @@ export function useSyncOnReconnect() {
}
init()
return () => {
if (pendingPollInterval) {
clearInterval(pendingPollInterval)
}
if (cacheRefreshInterval) {
clearInterval(cacheRefreshInterval)
}
if (networkListener) {
networkManager.unregisterNetworkListener(networkListener)
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('online', handleOnline)
resumeListener?.remove()
}
}, [queryClient])
}

View File

@@ -150,7 +150,7 @@ export const useCreateChore = () => {
return { ...oldData, res: [...oldData.res, offlineChore] }
})
return { res: offlineChore }
return offlineChore
}
return useMutation({
@@ -170,11 +170,11 @@ export const useCreateChore = () => {
}
// Successfully created the chore on the server, return the created chore
// update the local chores cache with the new chore:
queryClient.setQueryData(['chores'], oldData => {
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return { res: createdChore }
return createdChore.res
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineCreate(newTask)

View File

@@ -36,7 +36,7 @@ export const useCircleMembers = () => {
const result = await GetAllCircleMembers()
// Cache for offline use
if (result?.res) {
offlineDB.saveKV('circle_members', result.res)
offlineDB.saveKV('circle_members', result.res).catch(() => {})
}
return result
} catch {

View File

@@ -966,7 +966,6 @@ class OfflineDB {
async getHistoryByChore(choreId) {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
console.log('MO: Fetching history for chore', choreId)
return this.backend.getHistoryByChore(choreId)
}

View File

@@ -367,7 +367,11 @@ const ChoreEdit = () => {
SaveFunction(chore)
.then(result => {
if (result?._pendingUpdate || result?._pendingCreate) {
if (
result?._pendingUpdate ||
result?._pendingCreate ||
result?.res?._pendingCreate
) {
showSuccess({
title: 'Saved Offline',
message: 'Your changes will sync when you are back online.',

View File

@@ -907,8 +907,8 @@ const ChoreView = () => {
}}
>
{(() => {
const content = decodeHtmlEntities(chore.description || '')
const shouldRenderHtml = hasHtmlTags(content)
const raw = chore.description || ''
const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? (
<Box
@@ -916,7 +916,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: content }}
dangerouslySetInnerHTML={{ __html: raw }}
/>
) : (
<Typography
@@ -926,7 +926,7 @@ const ChoreView = () => {
wordBreak: 'break-word',
}}
>
{content}
{decodeHtmlEntities(raw)}
</Typography>
)
})()}
@@ -975,8 +975,8 @@ const ChoreView = () => {
}}
>
{(() => {
const content = decodeHtmlEntities(chore.notes || '')
const shouldRenderHtml = hasHtmlTags(content)
const raw = chore.notes || ''
const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? (
<Box
@@ -984,7 +984,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: content }}
dangerouslySetInnerHTML={{ __html: raw }}
/>
) : (
<Typography
@@ -994,7 +994,7 @@ const ChoreView = () => {
wordBreak: 'break-word',
}}
>
{content}
{decodeHtmlEntities(raw)}
</Typography>
)
})()}

View File

@@ -1,5 +1,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
CreateProject,
DeleteProject,
GetProjects,
UpdateProject,
} from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
// Query hook for fetching all projects
@@ -33,7 +38,7 @@ export const useCreateProject = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (projectData) => {
mutationFn: async projectData => {
try {
const response = await CreateProject(projectData)
if (response.ok) {
@@ -53,7 +58,7 @@ export const useCreateProject = () => {
return localProject
}
},
onSuccess: (newProject) => {
onSuccess: newProject => {
// Update the projects cache
queryClient.setQueryData(['projects'], (oldProjects = []) => {
const updatedProjects = [...oldProjects, newProject]
@@ -63,7 +68,7 @@ export const useCreateProject = () => {
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
onError: error => {
console.error('Create project mutation failed:', error)
},
})
@@ -92,18 +97,18 @@ export const useUpdateProject = () => {
}
}
},
onSuccess: (updatedProject) => {
onSuccess: updatedProject => {
// Update the projects cache
queryClient.setQueryData(['projects'], (oldProjects = []) => {
return oldProjects.map(project =>
project.id === updatedProject.id ? updatedProject : project
project.id === updatedProject.id ? updatedProject : project,
)
})
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
onError: error => {
console.error('Update project mutation failed:', error)
},
})
@@ -114,7 +119,7 @@ export const useDeleteProject = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (projectId) => {
mutationFn: async projectId => {
try {
// Prevent deletion of default project
if (projectId === 'default') {
@@ -141,14 +146,14 @@ export const useDeleteProject = () => {
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
onError: error => {
console.error('Delete project mutation failed:', error)
},
})
}
// Hook to get a specific project by ID
export const useProject = (projectId) => {
export const useProject = projectId => {
return useQuery({
queryKey: ['projects', projectId],
queryFn: async () => {